diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 8319b77..cb4b5f9 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -1,111 +1,27 @@ import { NextResponse, type NextRequest } from "next/server"; import { statusLabel } from "@/lib/absence"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; -import { fmtName, todayIso } from "@/lib/format"; -import { subtreeOf } from "@/lib/org"; -import { loadPlacements, loadReportingLineMap } from "@/lib/placement"; -import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria"; -import { derivedStatusFilter } from "@/lib/employee-status-filter"; +import { exportParameter, ladeExportMitarbeiter, type ExportMitarbeiter } from "@/lib/export-auswahl"; import { sortiereWochentage } from "@/lib/wochentage"; -import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; -import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; +import { deriveStatusAsOf, type OrgLookups } from "@/lib/reports"; import { requireHrUser } from "@/lib/auth/require-hr"; import { withUser } from "@/lib/db"; -import type { Database } from "@/lib/types"; -// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt -// über die Planstelle und die abgeleitete Berichtslinie. -type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & { - org_unit_id: string | null; - position_number: string | null; - is_chief: boolean; - manager_id: string | null; -}; +type EmployeeRow = ExportMitarbeiter; // Full raw data dump — every column on `employees`, not just the fields a // pivot report groups by. Respects the same division/location/status/ -// employment filters as the Berichte page. With `asOf` (Stichtag), status -// filtering happens against the *derived* status as of that date rather -// than the live `status` column — see deriveStatusAsOf. +// employment filters as the Berichte page. Wer darin steht, entscheidet +// lib/export-auswahl.ts — dieselbe Auswahl wie im Honestly-Export. export async function GET(request: NextRequest) { const gate = await requireHrUser(); if ("denied" in gate) return gate.denied; - const params = request.nextUrl.searchParams; - const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; - const asOf = parseIsoDateParam(params.get("asOf")); - const filters: ReportFilters = { - division: params.get("division") ?? undefined, - location: params.get("location") ?? undefined, - status: params.get("status") ?? undefined, - criteria: parseCriteria((k) => params.get(k)), - }; - - const statuses = parseStatuses(filters.status); - - const stichtag = asOf ?? todayIso(); - - const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser( - gate.userId, - async (tx) => { - function employeeQuery() { - let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); - if (filters.location) q = q.where("location_id", "=", filters.location); - // Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert, - // mit Stichtag weiter unten über die Ableitung. Das sind zwei - // verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach, - // sobald ein Austritt mit einem damals künftigen Datum erfasst wurde - // (Migration 20260814100000 zieht sie nicht nach). Der Export nach - // „Ausgetreten" liess damit genau die Leute aus, die gerade - // ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL - // und einmal im Speicher. - if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true)); - // Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die - // Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein. - return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); - } - - 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(), - loadDependentsCounts(tx), - loadPlacements(tx, { asOf: stichtag }), - loadReportingLineMap(tx, stichtag), - ]); - - return { - employees, - lookups: lookupResult.lookups, - orgMaps: lookupResult.orgMaps, - allEmployees, - dependentsCounts, - placements, - lines, - }; - } + const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams); + const { rows, lookups, managerName, dependentsCounts } = await withUser(gate.userId, (tx) => + ladeExportMitarbeiter(tx, { asOf, filters }) ); - const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])); - // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein - // Export für "Produktion" nur die Bereichsleitung. - const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; - - const criteria = filters.criteria ?? LEERE_CRITERIA; - const enriched: EmployeeRow[] = employees.flatMap((e) => { - const placement = placements.get(e.id); - const orgUnitId = placement?.current ? placement.orgUnitId : null; - if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return []; - if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return []; - return [{ - ...e, - org_unit_id: orgUnitId, - position_number: placement?.positionNumber ?? null, - is_chief: placement?.isChief ?? false, - manager_id: lines.get(e.id)?.acting_manager_id ?? null, - }]; - }); - const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched; const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf); const filename = exportFilename("mitarbeiter-export", format); diff --git a/app/api/export/honestly/route.ts b/app/api/export/honestly/route.ts new file mode 100644 index 0000000..11d35e7 --- /dev/null +++ b/app/api/export/honestly/route.ts @@ -0,0 +1,29 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export"; +import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl"; +import { baueHonestlyZeilen, honestlySpalten } from "@/lib/honestly"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; + +// Der Export für die Mitarbeiterbefragung bei Honestly — Spalten und feste +// Werte stehen in lib/honestly.ts. +// +// Dieselben Personen wie im vollständigen Export daneben, mit denselben +// Filtern (lib/export-auswahl.ts). Wer eine Befragung nur für einen Bereich +// oder nur für Aktive 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 { rows, lookups, orgMaps } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters })); + + const { zeilen, tiefe } = baueHonestlyZeilen(rows, orgMaps, lookups.locationName); + const spalten = honestlySpalten(tiefe); + const filename = exportFilename("honestly-export", format); + + const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "Honestly") : toCsv(zeilen, spalten); + // Derselbe Umweg über Blob wie im vollständigen Export (TS#59417). + return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); +} diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 31298a8..1f2f278 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -637,6 +637,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) { + {/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an + Ereignisse. Dieselben Filter wie der vollständige Export darüber + — was dort steht, steht auch hier. */} + {mode === "snapshot" && ( +
+

Honestly-Report

+

+ Teilnehmerliste für die Mitarbeiterbefragung: Personalnummer, E-Mail, Name, Standort und die + Organisationskette von der eigenen Einheit bis ganz oben. Sprache immer „de“, Rolle immer „Respondee“. +

+

Status im Export: {statusExportLabel}

+
+ + + CSV + + + + Excel + +
+
+ )} + {savedReports.length > 0 && (

Gespeicherte Berichte

diff --git a/lib/export-auswahl.ts b/lib/export-auswahl.ts new file mode 100644 index 0000000..9c07c90 --- /dev/null +++ b/lib/export-auswahl.ts @@ -0,0 +1,120 @@ +import type { Tx } from "./db"; +import { derivedStatusFilter } from "./employee-status-filter"; +import { fmtName, todayIso } from "./format"; +import { subtreeOf, type OrgMaps } from "./org"; +import { loadPlacements, loadReportingLineMap } from "./placement"; +import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "./report-criteria"; +import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "./reports"; +import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "./reports-data"; +import type { Database } from "./types"; + +// Wer in einem Personenexport steht — für alle Exporte dieselbe Antwort. +// +// Stand bis September 2026 in der Route des vollständigen Exports. Seit es +// den Honestly-Export daneben gibt, gäbe es sonst zwei Fassungen derselben +// Auswahl, und die beiden Dateien enthielten bei gleichen Filtern +// verschiedene Leute, ohne dass es jemand merkt. + +/** Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt + * über die Planstelle und die abgeleitete Berichtslinie. */ +export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & { + org_unit_id: string | null; + position_number: string | null; + is_chief: boolean; + manager_id: string | null; +}; + +export type ExportParameter = { + format: "csv" | "xlsx"; + asOf: string | undefined; + filters: ReportFilters; +}; + +export function exportParameter(params: URLSearchParams): ExportParameter { + return { + format: params.get("format") === "xlsx" ? "xlsx" : "csv", + asOf: parseIsoDateParam(params.get("asOf")), + filters: { + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + status: params.get("status") ?? undefined, + criteria: parseCriteria((k) => params.get(k)), + }, + }; +} + +/** + * Die Personen, die ein Export mit diesen Filtern enthält, samt Einordnung. + * + * Mit Stichtag wird der Status gegen den *abgeleiteten* Stand an diesem Tag + * geprüft, nicht gegen die Spalte `status` — siehe deriveStatusAsOf. + */ +export async function ladeExportMitarbeiter( + tx: Tx, + { asOf, filters }: Pick +): Promise<{ + rows: ExportMitarbeiter[]; + lookups: OrgLookups; + orgMaps: OrgMaps; + managerName: Map; + dependentsCounts: Map; +}> { + const statuses = parseStatuses(filters.status); + const stichtag = asOf ?? todayIso(); + const criteria = filters.criteria ?? LEERE_CRITERIA; + + function employeeQuery() { + let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id"); + if (filters.location) q = q.where("location_id", "=", filters.location); + // Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert, + // mit Stichtag weiter unten über die Ableitung. Das sind zwei + // verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach, + // sobald ein Austritt mit einem damals künftigen Datum erfasst wurde + // (Migration 20260814100000 zieht sie nicht nach). Der Export nach + // „Ausgetreten" liess damit genau die Leute aus, die gerade + // ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL + // und einmal im Speicher. + if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true)); + // Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die + // Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein. + return applyCriteria(q, criteria); + } + + 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(), + loadDependentsCounts(tx), + loadPlacements(tx, { asOf: stichtag }), + loadReportingLineMap(tx, stichtag), + ]); + + const orgMaps = lookupResult.orgMaps; + // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein + // Export für "Produktion" nur die Bereichsleitung. + const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; + + const enriched: ExportMitarbeiter[] = employees.flatMap((e) => { + const placement = placements.get(e.id); + const orgUnitId = placement?.current ? placement.orgUnitId : null; + if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return []; + if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return []; + return [ + { + ...e, + org_unit_id: orgUnitId, + position_number: placement?.positionNumber ?? null, + is_chief: placement?.isChief ?? false, + manager_id: lines.get(e.id)?.acting_manager_id ?? null, + }, + ]; + }); + + return { + rows: asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched, + lookups: lookupResult.lookups, + orgMaps, + managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])), + dependentsCounts, + }; +} diff --git a/lib/honestly.ts b/lib/honestly.ts new file mode 100644 index 0000000..e8b099c --- /dev/null +++ b/lib/honestly.ts @@ -0,0 +1,93 @@ +import type { ExportColumn } from "./export"; +import { ancestorsOf, type OrgMaps } from "./org"; + +// Der Export für Honestly (https://www.honestly.de/), die Plattform für +// Mitarbeiterbefragungen: eine Zeile je Person, die befragt werden soll. +// +// Zwei Spalten stehen fest, statt aus den Daten zu kommen — so ist es +// vorgegeben: die Sprache ist immer Deutsch, die Rolle immer „Respondee". +// Wer anders befragt werden soll, wird in Honestly umgestellt, nicht hier. +// +// Der Organisationsbaum kommt von unten nach oben: „OU" ist die Einheit der +// Person, „OU+1" die darüber, und so weiter bis zur obersten. Die Zahl dieser +// Spalten richtet sich nach der tiefsten Kette im Export. Wer weiter oben +// hängt, hat hinten leere Zellen — und die oberste Einheit steht deshalb +// nicht bei allen in derselben Spalte. So ist es bestellt. + +export const HONESTLY_SPRACHE = "de"; +export const HONESTLY_ROLLE = "Respondee"; + +export type HonestlyZeile = { + personalnummer: number | string | null; + email: string | null; + vorname: string; + nachname: string; + standort: string; + /** Die Einheit der Person zuerst, die oberste zuletzt. */ + kette: string[]; +}; + +/** + * Die Namen der Einheiten von der eigenen bis zur obersten. + * + * ancestorsOf liefert die Wurzel zuerst; hier wird es umgedreht, weil die + * Spalten bei der eigenen Einheit anfangen. + */ +export function orgKette(maps: OrgMaps, unitId: string | null | undefined): string[] { + return ancestorsOf(maps, unitId) + .map((u) => u.name) + .reverse(); +} + +/** Überschrift der n-ten Organisationsspalte: OU, OU+1, OU+2, … */ +export function ouUeberschrift(stufe: number): string { + return stufe === 0 ? "OU" : `OU+${stufe}`; +} + +/** + * Die Spalten in der Reihenfolge der Vorgabe. + * + * Mindestens eine OU-Spalte, auch wenn niemand im Export einer Einheit + * zugeordnet ist: eine Datei, deren Spalten je nach Inhalt verschwinden, + * bricht jede Zuordnung, die auf der anderen Seite eingerichtet wurde. + */ +export function honestlySpalten(tiefe: number): ExportColumn[] { + const ou: ExportColumn[] = Array.from({ length: Math.max(1, tiefe) }, (_, stufe) => ({ + header: ouUeberschrift(stufe), + get: (z) => z.kette[stufe] ?? "", + })); + return [ + { header: "Personalnummer", get: (z) => z.personalnummer }, + { header: "Email", get: (z) => z.email ?? "" }, + { header: "Firstname", get: (z) => z.vorname }, + { header: "Last Name", get: (z) => z.nachname }, + { header: "Language", get: () => HONESTLY_SPRACHE }, + { header: "Location", get: (z) => z.standort }, + ...ou, + { header: "Role", get: () => HONESTLY_ROLLE }, + ]; +} + +/** Aus den Exportzeilen die Honestly-Zeilen und die Zahl der OU-Spalten. */ +export function baueHonestlyZeilen( + personen: { + personnel_number: number | string | null; + email: string | null; + first_name: string; + last_name: string; + location_id: string; + org_unit_id: string | null; + }[], + maps: OrgMaps, + standortName: Map +): { zeilen: HonestlyZeile[]; tiefe: number } { + const zeilen = personen.map((p) => ({ + personalnummer: p.personnel_number, + email: p.email, + vorname: p.first_name, + nachname: p.last_name, + standort: standortName.get(p.location_id) ?? "", + kette: orgKette(maps, p.org_unit_id), + })); + return { zeilen, tiefe: Math.max(1, ...zeilen.map((z) => z.kette.length)) }; +} diff --git a/tests/unit/honestly.test.ts b/tests/unit/honestly.test.ts new file mode 100644 index 0000000..b6ad44b --- /dev/null +++ b/tests/unit/honestly.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { toCsv } from "@/lib/export"; +import { baueHonestlyZeilen, honestlySpalten, orgKette, ouUeberschrift } from "@/lib/honestly"; +import { buildOrgMaps, type OrgUnit } from "@/lib/org"; + +// Die Datei geht an eine fremde Plattform, die sie über Spaltennamen +// zuordnet. Ein Fehler darin zeigt sich nicht hier, sondern dort — als +// Befragung, die an die falschen Leute geht oder nach der falschen Einheit +// auswertet. Deshalb wird hier die Form festgehalten, nicht nur der Inhalt. + +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: "a", org_number: "3", name: "Waffel", parent_id: "b", unit_type: "Abteilung" }, + { id: "t", org_number: "4", name: "Linie 1", parent_id: "a", unit_type: "Team" }, + { id: "v", org_number: "5", name: "Vertrieb", parent_id: "g", unit_type: "Bereich" }, +]; +const maps = buildOrgMaps(units, []); +const standorte = new Map([["wien", "Wien"], ["wolkersdorf", "Wolkersdorf"]]); + +function person(teil: Partial[0][number]> = {}) { + return { + personnel_number: 4711, + email: "anna.berger@example.test", + first_name: "Anna", + last_name: "Berger", + location_id: "wien", + org_unit_id: "t", + ...teil, + }; +} + +describe("orgKette", () => { + it("fängt bei der eigenen Einheit an und endet ganz oben", () => { + // OU ist die Einheit der Person, OU+1 die darüber — so ist es bestellt. + // ancestorsOf liefert es andersherum; ohne das Umdrehen stünde die + // Gesellschaft in der Spalte „OU". + expect(orgKette(maps, "t")).toEqual(["Linie 1", "Waffel", "Produktion", "Manner AG"]); + }); + + it("ist leer ohne Einheit", () => { + expect(orgKette(maps, null)).toEqual([]); + }); +}); + +describe("Spalten", () => { + it("stehen in der vorgegebenen Reihenfolge", () => { + expect(honestlySpalten(3).map((s) => s.header)).toEqual([ + "Personalnummer", + "Email", + "Firstname", + "Last Name", + "Language", + "Location", + "OU", + "OU+1", + "OU+2", + "Role", + ]); + }); + + it("heissen OU, OU+1, OU+2 …", () => { + expect([0, 1, 2, 5].map(ouUeberschrift)).toEqual(["OU", "OU+1", "OU+2", "OU+5"]); + }); + + it("behalten mindestens eine OU-Spalte", () => { + // Eine Datei, deren Spalten je nach Inhalt verschwinden, bricht die + // Zuordnung, die auf der anderen Seite eingerichtet wurde. + expect(honestlySpalten(0).filter((s) => s.header.startsWith("OU"))).toHaveLength(1); + }); +}); + +describe("baueHonestlyZeilen", () => { + it("richtet die Zahl der OU-Spalten nach der tiefsten Kette", () => { + const { tiefe } = baueHonestlyZeilen([person({ org_unit_id: "v" }), person({ org_unit_id: "t" })], maps, standorte); + expect(tiefe).toBe(4); + }); + + it("füllt kürzere Ketten hinten mit Leerzellen auf", () => { + const { zeilen, tiefe } = baueHonestlyZeilen([person({ org_unit_id: "v" }), person()], maps, standorte); + const spalten = honestlySpalten(tiefe); + const werte = (i: number) => spalten.map((s) => s.get(zeilen[i])); + // Vertrieb hängt direkt unter der Gesellschaft: zwei Einheiten, zwei leere. + expect(werte(0).slice(6, 10)).toEqual(["Vertrieb", "Manner AG", "", ""]); + expect(werte(1).slice(6, 10)).toEqual(["Linie 1", "Waffel", "Produktion", "Manner AG"]); + }); + + it("setzt Sprache und Rolle fest", () => { + const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte); + const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])])); + expect(zeile.Language).toBe("de"); + expect(zeile.Role).toBe("Respondee"); + }); + + it("übernimmt die Personendaten unverändert", () => { + const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte); + const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])])); + expect(zeile).toMatchObject({ + Personalnummer: 4711, + Email: "anna.berger@example.test", + Firstname: "Anna", + "Last Name": "Berger", + Location: "Wien", + }); + }); + + it("lässt eine fehlende E-Mail leer, statt sie zu erfinden", () => { + // Die Adresse ist privat und freiwillig (20260811140000). Eine leere + // Zelle fällt in Honestly auf; ein Platzhalter bekäme eine Einladung. + const { zeilen } = baueHonestlyZeilen([person({ email: null })], maps, standorte); + expect(honestlySpalten(1)[1].get(zeilen[0])).toBe(""); + }); + + it("gibt Personen ohne Einheit eine leere Kette, aber eine Zeile", () => { + // Wer nicht zugeordnet ist, soll trotzdem befragt werden. + const { zeilen, tiefe } = baueHonestlyZeilen([person({ org_unit_id: null })], maps, standorte); + expect(zeilen).toHaveLength(1); + expect(tiefe).toBe(1); + expect(honestlySpalten(tiefe).find((s) => s.header === "OU")?.get(zeilen[0])).toBe(""); + }); +}); + +describe("als CSV", () => { + it("schreibt die Kopfzeile genau so, wie Honestly sie zuordnet", () => { + const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte); + const kopf = toCsv(zeilen, honestlySpalten(tiefe)).replace(/^/, "").split(/\r?\n/)[0]; + expect(kopf).toContain("Personalnummer"); + expect(kopf).toContain("Last Name"); + expect(kopf).toMatch(/OU\+3.*Role/); + }); +});