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}
+
+
+ )}
+
{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/);
+ });
+});