Compare commits
2 Commits
7b7562799d
...
683e7cc2d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 683e7cc2d7 | |||
| 779d6116b2 |
@@ -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);
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
</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
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