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_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 ): Promise<{ rows: ExportMitarbeiter[]; lookups: OrgLookups; orgMaps: OrgMaps; managerName: Map; /** Cornerstone-ID je Kennung — für Exporte, die über die Kennung des Zielsystems verweisen. */ managerKennung: Map; dependentsCounts: Map; }> { 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), // Absichtlich schmal: die Liste umfasst **alle** Personen, nicht nur die // gefilterten, und dient nur dazu, einen Verweis aufzulösen. tx.selectFrom("employees").select(["id", "first_name", "last_name", "cornerstone_id"]).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, // Die Tätigkeit der Planstelle, auf der die Person am Stichtag sitzt — // und nur ersatzweise die Spalte an der Person. Genau so löst die // Berichtsseite es auf (loadSnapshotEmployees); vorher stand hier die // Spalte allein, und damit konnte in der Akte eine andere Position // stehen als in der Datei daneben, ohne dass eines von beidem falsch // aussah. job_title: placement?.current ? placement.jobTitle : e.job_title, org_unit_id: orgUnitId, position_id: placement?.current ? placement.positionId : null, 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)])), managerKennung: new Map(allEmployees.map((e) => [e.id, e.cornerstone_id])), dependentsCounts, }; }