Files
alpenwerk-hr/app/api/export/employees/route.ts
Andrei Laas 6ab78d4b27 Uebernahme: extern auf intern ist ein eigenes Ereignis
Aus dem Gespraech vom 17.09.2026. Migrationen 20260917110000 (Enum-Wert)
und 20260917120000 (Logik) — getrennt, weil ein in derselben Transaktion
angelegter Enum-Wert dort noch nicht benutzt werden darf.

Die Besetzungsart (extern/intern) wurde bisher nur bei der Neueinstellung
gesetzt und war danach unerreichbar. Eine Angabe, die mit dem ersten Tag
erstarrt, obwohl gerade ihr Wechsel der Vorgang ist, um den es geht. Sie
steht jetzt in "Daten aendern".

Wechselt sie von Extern auf Intern, entsteht das Ereignis "Uebernahme" — in
der Personalakte und im Protokoll, und im Berichtemanager als Ereignistyp
auswertbar. Der umgekehrte Weg bekommt ausdruecklich keines: eine Uebernahme
zurueckzunehmen gibt es fachlich nicht.

Zwei Eintraege und nicht einer: die Vertragsaenderung haelt fest, dass ein
Feld sich geaendert hat und worauf (und laesst sich darueber zuruecknehmen),
das Ereignis, dass dieser Wechsel eine Uebernahme war. Nur das zweite laesst
sich zaehlen.

Eine auf spaeter datierte Uebernahme erzeugt das Ereignis sofort mit
pending_id, wie die Vertragsaenderung daneben; der Nachtlauf schreibt am
Stichtag nur noch die Spalte. Die Selbstpruefung haelt fest, dass er kein
zweites Ereignis schreibt — sonst staende die Uebernahme doppelt in der Akte
und jede Auswertung zaehlte sie zweimal.

Im Assistenten erscheint das Feld nicht: dort steht die Besetzungsart im
Schritt "Position", und zweimal danach zu fragen waere eine Einladung, zwei
verschiedene Antworten zu geben.
2026-09-17 12:10:50 +02:00

205 lines
11 KiB
TypeScript

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 { sortiereWochentage } from "@/lib/wochentage";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
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;
};
// 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.
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 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);
const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns);
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}
function employeeExportColumns(
lookups: OrgLookups,
managerName: Map<string, string>,
dependentsCounts: Map<string, number>,
asOf?: string
): ExportColumn<EmployeeRow>[] {
const columns: ExportColumn<EmployeeRow>[] = [
{ header: "Pers.-Nr.", get: (e) => e.personnel_number },
{ header: "Vorname", get: (e) => e.first_name },
{ header: "Nachname", get: (e) => e.last_name },
{ header: "Geschlecht", get: (e) => (e.gender === "m" ? "männlich" : "weiblich") },
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
{ header: "Adresse", get: (e) => e.address },
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Position", get: (e) => e.job_title },
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
{ header: "Planstelle", get: (e) => e.position_number },
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
// Seit lib/wochentage.ts wird sortiert gespeichert; der Bestand kann aber
// noch unsortierte Zeilen aus der Zeit davor tragen. Hier bleibt es
// deshalb stehen — im Export kostet es nichts und macht die Spalte
// unabhängig davon, wann eine Zeile zuletzt gespeichert wurde.
{ header: "Arbeitstage", get: (e) => sortiereWochentage(e.work_days).join(", ") },
{ header: "Vertragsart", get: (e) => e.contract_type },
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type },
{ header: "Mitarbeiterart", get: (e) => e.mitarbeiterart },
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
{ header: "Bescheid bis", get: (e) => e.behinderung_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },
{ header: "Herkunft", get: (e) => e.source },
// The export shows the display name, not the raw enum value — a payroll
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
// would just cause questions.
{ header: "Status", get: (e) => statusLabel(e.status) },
{ header: "Art der Langzeitabwesenheit", get: (e) => e.absence_type },
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
// Eigene Spalte neben dem Grund: aus ihm folgt sie nicht. Leer heisst
// „nicht erfasst" und nicht „weder noch".
{ header: "Freiwillig/unfreiwillig", get: (e) => e.austrittsart ?? "" },
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
];
if (asOf) {
const statusIndex = columns.findIndex((c) => c.header === "Status");
columns.splice(statusIndex + 1, 0, { header: `Status zum Stichtag (${asOf})`, get: (e) => deriveStatusAsOf(e, asOf) });
}
return columns;
}