Compare commits
2 Commits
7b7562799d
...
683e7cc2d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 683e7cc2d7 | |||
| 779d6116b2 |
@@ -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);
|
||||
|
||||
|
||||
29
app/api/export/honestly/route.ts
Normal file
29
app/api/export/honestly/route.ts
Normal 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) });
|
||||
}
|
||||
@@ -637,6 +637,31 @@ 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, 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 && (
|
||||
<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
120
lib/export-auswahl.ts
Normal 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
98
lib/honestly.ts
Normal 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
148
tests/unit/honestly.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user