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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === "snapshot" && (
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Cornerstone Report</h3>
|
||||||
|
<p className="mb-1 text-xs text-ink-muted">
|
||||||
|
Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum
|
||||||
|
TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und
|
||||||
|
Position ID — dafür fehlen die Kennungen des Zielsystems.
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<a href={exportHref("/api/export/cornerstone", "csv")} className={LINK_BUTTON_CLASS}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
CSV
|
||||||
|
</a>
|
||||||
|
<a href={exportHref("/api/export/cornerstone", "xlsx")} className={LINK_BUTTON_CLASS}>
|
||||||
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
|
Excel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{savedReports.length > 0 && (
|
{savedReports.length > 0 && (
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<div className="rounded border border-border bg-white p-4">
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3>
|
||||||
|
|||||||
334
lib/cornerstone.ts
Normal file
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. */
|
* über die Planstelle und die abgeleitete Berichtslinie. */
|
||||||
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
|
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
|
||||||
org_unit_id: string | null;
|
org_unit_id: string | null;
|
||||||
|
position_id: string | null;
|
||||||
position_number: string | null;
|
position_number: string | null;
|
||||||
is_chief: boolean;
|
is_chief: boolean;
|
||||||
manager_id: string | null;
|
manager_id: string | null;
|
||||||
@@ -57,6 +58,8 @@ export async function ladeExportMitarbeiter(
|
|||||||
lookups: OrgLookups;
|
lookups: OrgLookups;
|
||||||
orgMaps: OrgMaps;
|
orgMaps: OrgMaps;
|
||||||
managerName: Map<string, string>;
|
managerName: Map<string, string>;
|
||||||
|
/** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
|
||||||
|
managerNummer: Map<string, number>;
|
||||||
dependentsCounts: Map<string, number>;
|
dependentsCounts: Map<string, number>;
|
||||||
}> {
|
}> {
|
||||||
const statuses = parseStatuses(filters.status);
|
const statuses = parseStatuses(filters.status);
|
||||||
@@ -83,7 +86,7 @@ export async function ladeExportMitarbeiter(
|
|||||||
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||||
employeeQuery().execute(),
|
employeeQuery().execute(),
|
||||||
loadOrgLookups(tx),
|
loadOrgLookups(tx),
|
||||||
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
tx.selectFrom("employees").select(["id", "first_name", "last_name", "personnel_number"]).orderBy("id").execute(),
|
||||||
loadDependentsCounts(tx),
|
loadDependentsCounts(tx),
|
||||||
loadPlacements(tx, { asOf: stichtag }),
|
loadPlacements(tx, { asOf: stichtag }),
|
||||||
loadReportingLineMap(tx, stichtag),
|
loadReportingLineMap(tx, stichtag),
|
||||||
@@ -103,6 +106,7 @@ export async function ladeExportMitarbeiter(
|
|||||||
{
|
{
|
||||||
...e,
|
...e,
|
||||||
org_unit_id: orgUnitId,
|
org_unit_id: orgUnitId,
|
||||||
|
position_id: placement?.current ? placement.positionId : null,
|
||||||
position_number: placement?.positionNumber ?? null,
|
position_number: placement?.positionNumber ?? null,
|
||||||
is_chief: placement?.isChief ?? false,
|
is_chief: placement?.isChief ?? false,
|
||||||
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
|
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
|
||||||
@@ -115,6 +119,7 @@ export async function ladeExportMitarbeiter(
|
|||||||
lookups: lookupResult.lookups,
|
lookups: lookupResult.lookups,
|
||||||
orgMaps,
|
orgMaps,
|
||||||
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
|
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
|
||||||
|
managerNummer: new Map(allEmployees.map((e) => [e.id, e.personnel_number])),
|
||||||
dependentsCounts,
|
dependentsCounts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,20 +23,33 @@ export function sanitizeForSpreadsheetCell(text: string): string {
|
|||||||
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function csvCell(value: string | number | boolean | null): string {
|
function csvCell(value: string | number | boolean | null, trenner: string): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
||||||
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
|
||||||
|
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
|
||||||
|
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
|
/**
|
||||||
// makes umlauts render correctly instead of mojibake on open.
|
* Wie die Datei aussehen soll.
|
||||||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
*
|
||||||
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
|
* Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
|
||||||
|
* Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
|
||||||
|
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
|
||||||
|
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
|
||||||
|
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
|
||||||
|
*/
|
||||||
|
export type CsvForm = { trenner?: "," | ";"; bom?: boolean };
|
||||||
|
|
||||||
|
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
|
||||||
|
const trenner = form.trenner ?? ";";
|
||||||
|
const bom = form.bom ?? true;
|
||||||
|
const lines = [columns.map((c) => csvCell(c.header, trenner)).join(trenner)];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
|
lines.push(columns.map((c) => csvCell(c.get(row), trenner)).join(trenner));
|
||||||
}
|
}
|
||||||
return "" + lines.join("\r\n");
|
return (bom ? "" : "") + lines.join("\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
|
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
|
||||||
|
|||||||
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