Add the Honestly survey export to Berichte

A participant list for the employee survey on honestly.de, offered as
CSV and Excel right under the full data export. Columns: Personalnummer,
Email, Firstname, Last Name, Language, Location, OU, OU+1, ... , Role.
Language is always "de", Role always "Respondee".

The org columns run bottom-up: OU is the person's own unit, OU+1 the one
above, up to the top node. Their number follows the deepest chain in
the file, shorter chains are padded with empty cells, and there is
always at least one OU column so the file keeps its shape.

It exports exactly the people the full export would, with the same
filters. To guarantee that, the selection moved out of the full export's
route into lib/export-auswahl.ts and both routes use it; the full
export's columns are untouched.

Email is employees.email, which is the private address and optional --
there is no work address in the schema. Missing addresses stay empty
rather than getting a placeholder that would receive an invitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:04:45 +02:00
parent 7b7562799d
commit 779d6116b2
6 changed files with 405 additions and 92 deletions

View File

@@ -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);

View File

@@ -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) });
}

View File

@@ -637,6 +637,30 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div>
</div>
{/* 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" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
<p className="mb-1 text-xs text-ink-muted">
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“.
</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/honestly", "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref("/api/export/honestly", "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>

120
lib/export-auswahl.ts Normal file
View File

@@ -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<ExportParameter, "asOf" | "filters">
): Promise<{
rows: ExportMitarbeiter[];
lookups: OrgLookups;
orgMaps: OrgMaps;
managerName: Map<string, string>;
dependentsCounts: Map<string, number>;
}> {
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,
};
}

93
lib/honestly.ts Normal file
View File

@@ -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<HonestlyZeile>[] {
const ou: ExportColumn<HonestlyZeile>[] = 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<string, string>
): { 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)) };
}

131
tests/unit/honestly.test.ts Normal file
View File

@@ -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<Parameters<typeof baueHonestlyZeilen>[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/);
});
});