Cornerstone Report: system values, not display names
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>
This commit is contained in:
@@ -21,29 +21,15 @@ export async function GET(request: NextRequest) {
|
|||||||
const stichtag = asOf ?? todayIso();
|
const stichtag = asOf ?? todayIso();
|
||||||
|
|
||||||
const { zeilen } = await withUser(gate.userId, async (tx) => {
|
const { zeilen } = await withUser(gate.userId, async (tx) => {
|
||||||
const { rows, lookups, orgMaps, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
|
const { rows, managerNummer } = await ladeExportMitarbeiter(tx, { asOf, filters });
|
||||||
|
|
||||||
// Die beiden Angaben, die die gemeinsame Auswahl nicht mitbringt: die
|
// Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
|
||||||
// Kostenstelle hängt an der Planstelle, der Wiedereintritt an der
|
// Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
|
||||||
// Historie. Beide erst jetzt, weil erst jetzt feststeht, um wen es geht.
|
// 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 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 = {
|
const kontext: CornerstoneKontext = {
|
||||||
orgMaps,
|
|
||||||
standortName: lookups.locationName,
|
|
||||||
managerNummer,
|
managerNummer,
|
||||||
kostenstelle,
|
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
|
||||||
wiedereingestellt: new Set(wiedereintritte.map((w) => w.employee_id)),
|
|
||||||
stichtag,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
|
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
|
||||||
|
|||||||
@@ -659,10 +659,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
|
|
||||||
{mode === "snapshot" && (
|
{mode === "snapshot" && (
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<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>
|
<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">
|
<p className="mb-1 text-xs text-ink-muted">
|
||||||
Die 57 Spalten des Cornerstone-Einlesens, Datum tagzuerst. Vier Felder tragen noch Platzhalter
|
Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum
|
||||||
(Division ID, Doxis, Interflex, LGVplus) und sind vor einem echten Lauf zu ersetzen.
|
TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
|
||||||
|
Position ID — dafür fehlen die Kennungen des Zielsystems.
|
||||||
</p>
|
</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>
|
<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">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Kostenstelle } from "./cost-centers";
|
import type { Kostenstelle } from "./cost-centers";
|
||||||
import type { CsvForm, ExportColumn } from "./export";
|
import type { CsvForm, ExportColumn } from "./export";
|
||||||
import { unitOf, type OrgMaps } from "./org";
|
|
||||||
import type { EmploymentStatus } from "./types";
|
import type { EmploymentStatus } from "./types";
|
||||||
|
|
||||||
// Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge.
|
// Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge.
|
||||||
@@ -40,12 +39,36 @@ import type { EmploymentStatus } from "./types";
|
|||||||
export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false };
|
export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false };
|
||||||
|
|
||||||
export const CORNERSTONE_SPRACHE = "de-DE";
|
export const CORNERSTONE_SPRACHE = "de-DE";
|
||||||
export const CORNERSTONE_ZEITZONE = "CET";
|
|
||||||
export const CORNERSTONE_WAEHRUNG = "EUR";
|
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";
|
* 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 DOXIS_PRAEFIX = "DOX-";
|
||||||
export const LGV_PRAEFIX = "LGV-";
|
export const LGV_PRAEFIX = "LGV-";
|
||||||
|
|
||||||
@@ -75,16 +98,6 @@ export function csDatum(iso: string | null | undefined): string {
|
|||||||
return j && m && t ? `${t}.${m}.${j}` : "";
|
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.
|
* Der Anmeldename: vorname.nachname, klein, ohne Umlaute und Leerzeichen.
|
||||||
*
|
*
|
||||||
@@ -115,14 +128,21 @@ export function benutzername(vorname: string, nachname: string): string {
|
|||||||
* dem System oder sie gälte als arbeitend.
|
* dem System oder sie gälte als arbeitend.
|
||||||
*/
|
*/
|
||||||
export function kontoStatus(status: EmploymentStatus): string {
|
export function kontoStatus(status: EmploymentStatus): string {
|
||||||
return status === "Ausgetreten" ? "Inaktiv" : "Aktiv";
|
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 {
|
export function beschaeftigungsStatus(status: EmploymentStatus): string {
|
||||||
if (status === "Karenz") return "Abwesend";
|
if (status === "Karenz") return "On Leave";
|
||||||
if (status === "Ausgetreten") return "Ausgetreten";
|
if (status === "Ausgetreten") return "Terminated";
|
||||||
if (status === "Geplant") return "Eintritt geplant";
|
return "Working";
|
||||||
return "Arbeitend";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CornerstoneZeile = Record<string, string | number>;
|
export type CornerstoneZeile = Record<string, string | number>;
|
||||||
@@ -149,31 +169,24 @@ export type CornerstoneQuelle = {
|
|||||||
status: EmploymentStatus;
|
status: EmploymentStatus;
|
||||||
paygrade: string;
|
paygrade: string;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
org_unit_id: string | null;
|
|
||||||
position_id: string | null;
|
position_id: string | null;
|
||||||
position_number: string | null;
|
|
||||||
manager_id: string | null;
|
manager_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CornerstoneKontext = {
|
export type CornerstoneKontext = {
|
||||||
orgMaps: OrgMaps;
|
|
||||||
standortName: Map<string, string>;
|
|
||||||
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
|
/** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */
|
||||||
managerNummer: Map<string, number>;
|
managerNummer: Map<string, number>;
|
||||||
/** Kostenstelle je Planstelle zum Stichtag. */
|
/** Kostenstelle je Planstelle zum Stichtag. */
|
||||||
kostenstelle: Map<string, Kostenstelle>;
|
kostenstelle: Map<string, Kostenstelle>;
|
||||||
/** Wer schon einmal wiedereingetreten ist. */
|
|
||||||
wiedereingestellt: Set<string>;
|
|
||||||
stichtag: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
|
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
|
||||||
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
|
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
|
||||||
const nummer = p.personnel_number;
|
const nummer = p.personnel_number;
|
||||||
const name = benutzername(p.first_name, p.last_name);
|
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 kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined;
|
||||||
const chef = p.manager_id ? k.managerNummer.get(p.manager_id) : undefined;
|
const chef = p.manager_id ? k.managerNummer.get(p.manager_id) : undefined;
|
||||||
|
const ausgetreten = p.status === "Ausgetreten";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"User ID": String(nummer),
|
"User ID": String(nummer),
|
||||||
@@ -193,7 +206,9 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
|
|||||||
Gender: geschlecht(p.gender),
|
Gender: geschlecht(p.gender),
|
||||||
Exempt: "",
|
Exempt: "",
|
||||||
Ethnicity: "",
|
Ethnicity: "",
|
||||||
"Months of Service": dienstmonate(p.entry_date, k.stichtag),
|
// 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,
|
"Compensation Currency": CORNERSTONE_WAEHRUNG,
|
||||||
"Address Line 1": p.address ?? "",
|
"Address Line 1": p.address ?? "",
|
||||||
"Address Line 2": "",
|
"Address Line 2": "",
|
||||||
@@ -217,24 +232,35 @@ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext
|
|||||||
"Time Zone": CORNERSTONE_ZEITZONE,
|
"Time Zone": CORNERSTONE_ZEITZONE,
|
||||||
"User Type": CORNERSTONE_USER_TYPE,
|
"User Type": CORNERSTONE_USER_TYPE,
|
||||||
"Employment Status": beschaeftigungsStatus(p.status),
|
"Employment Status": beschaeftigungsStatus(p.status),
|
||||||
"Leave Reason": p.status === "Karenz" ? (p.absence_type ?? "") : "",
|
// Die vier Austrittsfelder und der Abwesenheitsgrund nur, wenn der
|
||||||
"Termination Type": p.austrittsart ?? "",
|
// Beschäftigungsstatus sie trägt. Ein Grund ohne Austritt ist für den
|
||||||
"Termination Reason": p.exit_reason ?? "",
|
// Load ein ungültiger Zustand, nicht eine Zusatzangabe.
|
||||||
"Termination Date": csDatum(p.exit_date),
|
"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": "",
|
"Eligible For Rehire": "",
|
||||||
// „Ja" nur, wenn die Historie einen Wiedereintritt trägt. Leer heisst
|
// „Nein" gehört laut Vorgabe nicht in die Datei, und der Wert für „Ja"
|
||||||
// „nie ausgetreten und zurückgekommen", nicht „unbekannt".
|
// ist nicht bestätigt. Ein nicht bestätigter Wert liesse den Load die
|
||||||
"Rehired Employee": k.wiedereingestellt.has(p.id) ? "Ja" : "",
|
// **ganze** Zeile abweisen, nicht nur das Feld — deshalb bleibt sie leer.
|
||||||
"Division ID": DIVISION_PLATZHALTER,
|
"Rehired Employee": "",
|
||||||
"Position ID": p.position_number ?? "",
|
"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,
|
"Grade ID": p.paygrade,
|
||||||
"Cost Center ID": kst?.code ?? "",
|
"Cost Center ID": kst?.code ?? "",
|
||||||
"Location ID": k.standortName.get(p.location_id) ?? "",
|
// 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": "",
|
"Retention Rules": "",
|
||||||
// Die Ebene, auf der die Person hängt — Gesellschaft, Bereich, Abteilung
|
Organisationsstufe: "",
|
||||||
// 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 AD": name,
|
||||||
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
|
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
|
||||||
"Customfield ID Interflex": String(nummer),
|
"Customfield ID Interflex": String(nummer),
|
||||||
|
|||||||
@@ -7,66 +7,58 @@ import {
|
|||||||
CORNERSTONE_SPALTEN,
|
CORNERSTONE_SPALTEN,
|
||||||
cornerstoneSpalten,
|
cornerstoneSpalten,
|
||||||
csDatum,
|
csDatum,
|
||||||
dienstmonate,
|
DIVISION_GUID,
|
||||||
geschlecht,
|
geschlecht,
|
||||||
kontoStatus,
|
kontoStatus,
|
||||||
type CornerstoneKontext,
|
type CornerstoneKontext,
|
||||||
type CornerstoneQuelle,
|
type CornerstoneQuelle,
|
||||||
} from "@/lib/cornerstone";
|
} from "@/lib/cornerstone";
|
||||||
import { toCsv } from "@/lib/export";
|
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
|
// Die Datei geht in ein fremdes System, das sie über eine im Portal
|
||||||
// eingerichtete Zuordnung einliest. Eine verschobene Spalte oder ein Datum in
|
// eingerichtete Zuordnung einliest. Zwei Sorten Fehler sind hier teuer:
|
||||||
// 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
|
// * Ein deutscher Anzeigename statt des Systemwerts („Aktiv" statt
|
||||||
// sind. Deshalb steht die Form hier fest.
|
// „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 Vorgabe, Zeichen für Zeichen.
|
// Die Kopfzeile aus der Vorlage, Zeichen für Zeichen.
|
||||||
const VORGABE =
|
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";
|
"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 = {
|
const kontext: CornerstoneKontext = {
|
||||||
orgMaps: buildOrgMaps(units, []),
|
|
||||||
standortName: new Map([["wien", "Wien"]]),
|
|
||||||
managerNummer: new Map([["chef", 1000]]),
|
managerNummer: new Map([["chef", 1000]]),
|
||||||
kostenstelle: new Map([["pos-1", { id: "k", code: "4711", name: "Waffelproduktion" }]]),
|
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
|
||||||
wiedereingestellt: new Set(["zurueck"]),
|
|
||||||
stichtag: "2026-09-25",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
|
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
|
||||||
return {
|
return {
|
||||||
id: "e1",
|
id: "e1",
|
||||||
personnel_number: 9001,
|
personnel_number: 9002,
|
||||||
first_name: "Testa",
|
first_name: "Max",
|
||||||
last_name: "Musterfrau",
|
last_name: "Alpentest",
|
||||||
title_prefix: [],
|
title_prefix: [],
|
||||||
title_suffix: [],
|
title_suffix: [],
|
||||||
gender: "w",
|
gender: "m",
|
||||||
company_email: "testa.musterfrau@example.test",
|
company_email: "m.test@alpenwerk.test.com",
|
||||||
address: "Teststrasse 1",
|
address: "Teststrasse 2",
|
||||||
postal_code: "1010",
|
postal_code: "1010",
|
||||||
city: "Wien",
|
city: "Wien",
|
||||||
address_country: "AT",
|
address_country: "AT",
|
||||||
entry_date: "2026-09-15",
|
entry_date: "2026-10-01",
|
||||||
exit_date: null,
|
exit_date: null,
|
||||||
exit_reason: null,
|
exit_reason: null,
|
||||||
austrittsart: null,
|
austrittsart: null,
|
||||||
absence_type: null,
|
absence_type: null,
|
||||||
status: "Aktiv",
|
status: "Aktiv",
|
||||||
paygrade: "B",
|
paygrade: "HG15",
|
||||||
location_id: "wien",
|
location_id: "wien",
|
||||||
org_unit_id: "t",
|
|
||||||
position_id: "pos-1",
|
position_id: "pos-1",
|
||||||
position_number: "S-0001",
|
manager_id: null,
|
||||||
manager_id: "chef",
|
|
||||||
...teil,
|
...teil,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -74,7 +66,7 @@ function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
|
|||||||
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
|
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
|
||||||
|
|
||||||
describe("Spalten", () => {
|
describe("Spalten", () => {
|
||||||
it("stehen genau so und in genau der Reihenfolge wie vorgegeben", () => {
|
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
|
||||||
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
|
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,27 +74,45 @@ describe("Spalten", () => {
|
|||||||
expect(CORNERSTONE_SPALTEN).toHaveLength(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", () => {
|
it("decken sich mit den Feldern, die eine Zeile liefert", () => {
|
||||||
// Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge
|
// Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge
|
||||||
// stillschweigend verloren.
|
// stillschweigend verloren.
|
||||||
expect(Object.keys(zeile()).sort()).toEqual([...CORNERSTONE_SPALTEN].sort());
|
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("Einzelne Umsetzungen", () => {
|
describe("Systemwerte statt Anzeigenamen", () => {
|
||||||
it("schreibt das Datum mit dem Tag zuerst", () => {
|
it("setzt Status auf Active beziehungsweise Inactive", () => {
|
||||||
// Die Einleseeinstellung steht auf „Tag zuerst". Ein ISO-Datum würde
|
expect(kontoStatus("Aktiv")).toBe("Active");
|
||||||
// stumm falsch gelesen.
|
expect(kontoStatus("Karenz")).toBe("Active");
|
||||||
expect(csDatum("2026-09-15")).toBe("15.09.2026");
|
expect(kontoStatus("Ausgetreten")).toBe("Inactive");
|
||||||
expect(csDatum(null)).toBe("");
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("übersetzt das Geschlecht in Cornerstones Werte", () => {
|
||||||
@@ -113,124 +123,151 @@ describe("Einzelne Umsetzungen", () => {
|
|||||||
expect(geschlecht("x")).toBe("not specified");
|
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", () => {
|
it("baut den Anmeldenamen ohne Umlaute und Sonderzeichen", () => {
|
||||||
expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross");
|
expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross");
|
||||||
expect(benutzername("Anna Maria", "Berger")).toBe("annamaria.berger");
|
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", () => {
|
describe("Eine Zeile gegen das Muster vom 27.09.2026", () => {
|
||||||
it("entspricht der Beispielzeile in den tragenden Feldern", () => {
|
it("trägt dieselben Werte wie der Testload", () => {
|
||||||
const z = zeile();
|
expect(zeile()).toMatchObject({
|
||||||
expect(z).toMatchObject({
|
"Local System ID": "9002",
|
||||||
"Local System ID": "9001",
|
"First Name": "Max",
|
||||||
"First Name": "Testa",
|
"Last Name": "Alpentest",
|
||||||
"Last Name": "Musterfrau",
|
Username: "max.alpentest",
|
||||||
Username: "testa.musterfrau",
|
Email: "m.test@alpenwerk.test.com",
|
||||||
Gender: "female",
|
Gender: "male",
|
||||||
"Compensation Currency": "EUR",
|
"Compensation Currency": "EUR",
|
||||||
"Address Line 1": "Teststrasse 1",
|
"Address Line 1": "Teststrasse 2",
|
||||||
City: "Wien",
|
City: "Wien",
|
||||||
Zip: "1010",
|
Zip: "1010",
|
||||||
Country: "AT",
|
Country: "AT",
|
||||||
"Original Hire Date": "15.09.2026",
|
"Original Hire Date": "01.10.2026",
|
||||||
"Last Hire Date": "15.09.2026",
|
"Last Hire Date": "01.10.2026",
|
||||||
Status: "Aktiv",
|
Status: "Active",
|
||||||
Language: "de-DE",
|
Language: "de-DE",
|
||||||
"Time Zone": "CET",
|
"Time Zone": "",
|
||||||
"User Type": "Mitarbeiter",
|
"User Type": "Employee",
|
||||||
"Employment Status": "Arbeitend",
|
"Employment Status": "Working",
|
||||||
"Customfield ID AD": "testa.musterfrau",
|
"Division ID": DIVISION_GUID,
|
||||||
"Customfield ID Doxis": "DOX-9001",
|
"Grade ID": "HG15",
|
||||||
"Customfield ID Interflex": "9001",
|
"Cost Center ID": "41130",
|
||||||
"Customfield ID LGVplus": "LGV-9001",
|
"Customfield ID AD": "max.alpentest",
|
||||||
|
"Customfield ID Doxis": "DOX-9002",
|
||||||
|
"Customfield ID Interflex": "9002",
|
||||||
|
"Customfield ID LGVplus": "LGV-9002",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verweist auf die vorgesetzte Person über deren Personalnummer", () => {
|
it("lässt die Felder leer, die das Muster leer lässt", () => {
|
||||||
// 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();
|
const z = zeile();
|
||||||
expect(z.Email).toBe("testa.musterfrau@example.test");
|
for (const feld of [
|
||||||
expect(z["Personal Email"]).toBe("");
|
"Prefix",
|
||||||
expect(z["Home Phone"]).toBe("");
|
"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("");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("setzt die Kostenstelle der Planstelle, nicht der Person", () => {
|
describe("Abhängige Felder", () => {
|
||||||
expect(zeile()["Cost Center ID"]).toBe("4711");
|
it("füllt die Austrittsfelder nur bei Terminated", () => {
|
||||||
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
|
// 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" });
|
||||||
it("nennt als Organisationsstufe die Ebene, nicht den Namen der Einheit", () => {
|
expect(offen["Termination Date"]).toBe("");
|
||||||
expect(zeile().Organisationsstufe).toBe("Team");
|
expect(offen["Termination Type"]).toBe("");
|
||||||
expect(zeile({ org_unit_id: "b" }).Organisationsstufe).toBe("Bereich");
|
expect(offen["Termination Reason"]).toBe("");
|
||||||
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" });
|
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 Date"]).toBe("31.12.2026");
|
||||||
expect(weg["Termination Type"]).toBe("freiwillig");
|
expect(weg["Termination Type"]).toBe("freiwillig");
|
||||||
expect(weg["Termination Reason"]).toBe("Kündigung");
|
expect(weg["Termination Reason"]).toBe("Kündigung");
|
||||||
expect(weg.Status).toBe("Inaktiv");
|
expect(weg.Status).toBe("Inactive");
|
||||||
|
expect(weg["Employment Status"]).toBe("Terminated");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("nennt den Abwesenheitsgrund nur bei Karenz", () => {
|
it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
|
||||||
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
|
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
|
||||||
expect(zeile({ status: "Karenz", absence_type: "Elternkarenz" })["Leave Reason"]).toBe("Elternkarenz");
|
const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
|
||||||
|
expect(karenz["Leave Reason"]).toBe("Elternkarenz");
|
||||||
|
expect(karenz["Employment Status"]).toBe("On Leave");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kennzeichnet Wiedereingestellte", () => {
|
it("nennt keinen Abwesenheitsgrund an einer ausgetretenen Person", () => {
|
||||||
expect(zeile()["Rehired Employee"]).toBe("");
|
const weg = zeile({ status: "Ausgetreten", absence_type: "Elternkarenz", exit_date: "2026-12-31" });
|
||||||
expect(zeile({ id: "zurueck" })["Rehired Employee"]).toBe("Ja");
|
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("trägt die Platzhalter sichtbar ein", () => {
|
it("nimmt als Kostenstelle den Code der Planstelle", () => {
|
||||||
// Sie müssen vor einem echten Lauf ersetzt werden — ein leeres Feld
|
expect(zeile()["Cost Center ID"]).toBe("41130");
|
||||||
// sähe aus wie „nichts zu tun".
|
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
|
||||||
expect(zeile()["Division ID"]).toBe("DIVISION-ID-HIER");
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("als CSV", () => {
|
||||||
it("schreibt die Kopfzeile Zeichen für Zeichen wie vorgegeben", () => {
|
it("schreibt die Kopfzeile Zeichen für Zeichen wie die Vorlage", () => {
|
||||||
// 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);
|
const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||||
expect(csv.startsWith("User ID,")).toBe(true);
|
expect(csv.startsWith("User ID,")).toBe(true);
|
||||||
expect(csv.split(/\r?\n/)[0]).toBe(VORGABE);
|
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", () => {
|
it("maskiert ein Komma im Wert, statt die Zeile zu zerreissen", () => {
|
||||||
const csv = toCsv([zeile({ address: "Teststrasse 1, Stiege 2" })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
const csv = toCsv([zeile({ address: "Teststrasse 2, Stiege 3" })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||||
expect(csv).toContain('"Teststrasse 1, Stiege 2"');
|
expect(csv).toContain('"Teststrasse 2, Stiege 3"');
|
||||||
// Felder zählen, nicht Kommas: das maskierte Komma steht ja in der Zeile.
|
// 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
|
const felder = (t: string) => t.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
|
||||||
// Adresse jede weitere Spalte um eins.
|
|
||||||
const felder = (zeileText: string) => zeileText.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
|
|
||||||
const [kopf, daten] = csv.split(/\r?\n/);
|
const [kopf, daten] = csv.split(/\r?\n/);
|
||||||
expect(felder(daten)).toBe(felder(kopf));
|
expect(felder(daten)).toBe(felder(kopf));
|
||||||
expect(felder(kopf)).toBe(57);
|
expect(felder(kopf)).toBe(57);
|
||||||
|
|||||||
Reference in New Issue
Block a user