Compare commits

..

2 Commits

Author SHA1 Message Date
683e7cc2d7 Keep private email out of the Honestly export
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
The Email column exported employees.email, which is the person's
private address. That does not belong in a file sent to an outside
survey provider, least of all as the address invitations go to in the
employer's name.

The column now stays as a placeholder for the work email, which does
not exist in the schema yet and will be added later. It is empty until
then, but keeps its place so the column mapping set up in Honestly does
not have to change once the address arrives. The export no longer
reads employees.email at all, so it cannot end up in another column
by accident either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:44 +02:00
779d6116b2 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>
2026-09-18 15:10:43 +02:00
6 changed files with 428 additions and 92 deletions

View File

@@ -1,111 +1,27 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence"; import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { fmtName, todayIso } from "@/lib/format"; import { exportParameter, ladeExportMitarbeiter, type ExportMitarbeiter } from "@/lib/export-auswahl";
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 { sortiereWochentage } from "@/lib/wochentage"; import { sortiereWochentage } from "@/lib/wochentage";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { deriveStatusAsOf, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr"; import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db"; import { withUser } from "@/lib/db";
import type { Database } from "@/lib/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt type EmployeeRow = ExportMitarbeiter;
// ü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;
};
// Full raw data dump — every column on `employees`, not just the fields a // Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/ // pivot report groups by. Respects the same division/location/status/
// employment filters as the Berichte page. With `asOf` (Stichtag), status // employment filters as the Berichte page. Wer darin steht, entscheidet
// filtering happens against the *derived* status as of that date rather // lib/export-auswahl.ts — dieselbe Auswahl wie im Honestly-Export.
// than the live `status` column — see deriveStatusAsOf.
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const gate = await requireHrUser(); const gate = await requireHrUser();
if ("denied" in gate) return gate.denied; if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams; const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const { rows, lookups, managerName, dependentsCounts } = await withUser(gate.userId, (tx) =>
const asOf = parseIsoDateParam(params.get("asOf")); ladeExportMitarbeiter(tx, { asOf, filters })
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 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 columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format); 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,31 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div> </div>
</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, Name, Standort und die
Organisationskette von der eigenen Einheit bis ganz oben. Sprache immer „de“, Rolle immer „Respondee“.
Die Spalte „Email“ bleibt leer, bis die Firmen-E-Mail-Adresse erfasst wird.
</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 && ( {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>

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,
};
}

98
lib/honestly.ts Normal file
View File

@@ -0,0 +1,98 @@
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.
// Die E-Mail-Spalte ist ein Platzhalter für die **Firmenadresse**, die es
// im Datenmodell noch nicht gibt. employees.email ist die private Adresse
// (Migration 20260811140000) und gehört nicht in eine Datei, die an einen
// fremden Anbieter geht — schon gar nicht als Adresse, an die Einladungen im
// Namen des Arbeitgebers gehen. Deshalb bleibt die Spalte leer, bis die
// Firmenadresse erfasst wird; die Spalte selbst bleibt stehen, damit die
// Zuordnung in Honestly nicht neu eingerichtet werden muss.
export const HONESTLY_SPRACHE = "de";
export const HONESTLY_ROLLE = "Respondee";
export type HonestlyZeile = {
personalnummer: number | 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 },
// Platzhalter für die Firmenadresse — siehe oben.
{ header: "Email", get: () => "" },
{ 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;
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,
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)) };
}

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

@@ -0,0 +1,148 @@
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,
// Die private Adresse, wie sie auf der Zeile aus der Datenbank steht —
// sie darf im Export nirgends auftauchen.
email: "anna.privat@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,
Firstname: "Anna",
"Last Name": "Berger",
Location: "Wien",
});
});
it("lässt die E-Mail leer, bis es die Firmenadresse gibt", () => {
// Die Spalte ist ein Platzhalter für die Firmen-E-Mail. Die private
// Adresse (employees.email) gehört nicht in eine Datei an einen fremden
// Anbieter — auch nicht, wenn sie vorhanden ist.
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])]));
expect(zeile.Email).toBe("");
});
it("trägt die private Adresse in keine Spalte ein", () => {
// Nicht nur „Email" prüfen: rutschte sie in eine andere Spalte, fiele es
// dort erst in Honestly auf.
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const csv = toCsv(zeilen, honestlySpalten(tiefe));
expect(csv).not.toContain("anna.privat@example.test");
});
it("behält die Spalte Email, auch solange sie leer ist", () => {
// Die Zuordnung in Honestly wird einmal eingerichtet. Kommt die
// Firmenadresse später dazu, soll sich an der Datei nur der Inhalt ändern.
expect(honestlySpalten(1).map((s) => s.header)).toContain("Email");
});
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/);
});
});