Compare commits
2 Commits
e11544b8da
...
410acfe01f
| Author | SHA1 | Date | |
|---|---|---|---|
| 410acfe01f | |||
| 478364e2d0 |
46
app/api/export/cornerstone/route.ts
Normal file
46
app/api/export/cornerstone/route.ts
Normal 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) });
|
||||
}
|
||||
@@ -657,6 +657,28 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
334
lib/cornerstone.ts
Normal file
334
lib/cornerstone.ts
Normal 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] ?? "" }));
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
/** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
|
||||
managerNummer: Map<string, number>;
|
||||
dependentsCounts: Map<string, number>;
|
||||
}> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<T>(rows: T[], columns: ExportColumn<T>[]): 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<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) {
|
||||
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
|
||||
|
||||
280
tests/unit/cornerstone.test.ts
Normal file
280
tests/unit/cornerstone.test.ts
Normal 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("@");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user