Files
alpenwerk-hr/lib/export-auswahl.ts
Maximilian Stubhan 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

121 lines
5.0 KiB
TypeScript

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