Files
alpenwerk-hr/lib/reports-data.ts
Andrei Laas 9ded472d25 Freiwillig oder unfreiwillig wird erhoben, nicht abgeleitet
Migration 20260917100000. Ruecknahme einer eigenen Entscheidung, nach der
Erklaerung des Kunden am 17.09.2026.

Wir hatten den Anstoss aus der Beendigungsart abgeleitet — Kuendigung AN
gilt als freiwillig, Kuendigung AG als unfreiwillig. Das schien sauberer,
weil es zwei Felder ausschliesst, die einander widersprechen koennen.

In Oesterreich stimmt es nicht. Die einvernehmliche Aufloesung ist hier der
Regelfall und sagt ueber den Anstoss nichts aus: sie kann von der Person
ausgehen ("ich moechte kuendigen", worauf einvernehmlich aufgeloest wird,
damit das AMS zahlt) oder vom Dienstgeber ("ich will die Trennung, dafuer
gibt es eine Abfindung"). Dieselbe Beendigungsart, zwei gegensaetzliche
Antworten — und das ist genau die Unterscheidung, auf die es bei einer
Fluktuationsanalyse ankommt. Die Ableitung haette die Haelfte der Faelle
still falsch einsortiert.

Im Formular steht jetzt die Beendigungsart oben mit allen Werten, darunter
"Freiwillig oder unfreiwillig". Keine der beiden schraenkt die andere ein.
Der abgeleitete Hinweis unter der Beendigungsart ist weg — er erschien von
selbst und sah aus wie ein Fehler des Formulars.

Die Angabe ist freiwillig: der Bestand traegt sie nicht, und ein
Befristungsablauf geschieht auf niemandes Betreiben. Ob sie fuer gewoehnliche
Austritte Pflicht werden soll, ist eine Frage an den Kunden.

rehire_employee raeumt sie mit dem Austritt weg. Die Funktion ist dabei
ausgeschrieben worden; die Selbstpruefung haelt fest, dass die beiden
Umstellungen, die sie schon hinter sich hatte (app_current_user_id statt
auth.uid, fester search_path), dabei nicht verlorengehen — genau das ist der
Fehler, den ein create-or-replace aus einer alten Vorlage leise macht.
2026-09-17 12:04:53 +02:00

384 lines
16 KiB
TypeScript

import type { SelectQueryBuilder } from "kysely";
import type { Schema } from "./db/schema";
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import type { Austrittsart } from "./types";
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import {
deriveStatusAsOf,
EREIGNIS_UNTERTYP,
EVENT_DATE_OPEN,
parseStatuses,
todayIso,
type OrgLookups,
type ReportEmployee,
type ReportEvent,
type UnitOption,
} from "./reports";
import type {
CollectiveAgreement,
ContractType,
DienstwagenArt,
EmploymentType,
GenderType,
HistoryEventType,
KuendigungsschutzGrund,
Mitarbeiterart,
PaygradeType,
SourceType,
TeilzeitArt,
WorkerType,
} from "./types";
// Shared by the Berichte page and /api/export/* so they can never drift on
// what "the current view" means — same filters, same stichtag/event-window
// rules.
export type ReportFilters = {
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
division?: string;
location?: string;
status?: string;
/** Alles Weitere — siehe lib/report-criteria.ts. */
criteria?: Criteria;
};
/**
* Macht aus den Kriterien Bedingungen auf `employees`.
*
* Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft
* der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine
* blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe
* Werteliste geprüft, aus der auch der Aufzählungstyp besteht.
*
* Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit
* (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine
* Spaltenbedingung ausdrücken.
*/
export function applyCriteria<O>(
q: SelectQueryBuilder<Schema, "employees", O>,
c: Criteria
): SelectQueryBuilder<Schema, "employees", O> {
const a = c.auswahl;
if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType);
if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType);
if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType);
if (a.mitarbeiterart) q = q.where("mitarbeiterart", "=", a.mitarbeiterart as Mitarbeiterart);
if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement);
if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType);
if (a.source) q = q.where("source", "=", a.source as SourceType);
if (a.gender) q = q.where("gender", "=", a.gender as GenderType);
if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt);
if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt);
if (a.absence) q = q.where("absence_type", "=", a.absence);
if (a.schutzGrund) q = q.where("kuendigungsschutz_grund", "=", a.schutzGrund as KuendigungsschutzGrund);
if (a.beendigung) q = q.where("exit_reason", "=", a.beendigung);
// Seit 20260917100000 eine eigene Spalte. Vorher wurde sie aus der
// Beendigungsart abgeleitet und der Filter zu einer Wertemenge aufgelöst —
// das ordnete die einvernehmlichen Auflösungen still einer Seite zu.
if (a.austrittsart) q = q.where("austrittsart", "=", a.austrittsart as Austrittsart);
const j = c.jaNein;
if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja");
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja");
// „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche.
if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null);
if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null);
if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja");
if (j.behindert) q = q.where("ist_beguenstigt_behindert", "=", j.behindert === "ja");
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von);
if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis);
if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von);
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von);
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
if (c.zeitraum.schutzAb?.von) q = q.where("kuendigungsschutz_ab", ">=", c.zeitraum.schutzAb.von);
if (c.zeitraum.schutzAb?.bis) q = q.where("kuendigungsschutz_ab", "<=", c.zeitraum.schutzAb.bis);
if (c.zeitraum.behinderungAb?.von) q = q.where("behinderung_ab", ">=", c.zeitraum.behinderungAb.von);
if (c.zeitraum.behinderungAb?.bis) q = q.where("behinderung_ab", "<=", c.zeitraum.behinderungAb.bis);
if (c.zeitraum.behinderungBis?.von) q = q.where("behinderung_bis", ">=", c.zeitraum.behinderungBis.von);
if (c.zeitraum.behinderungBis?.bis) q = q.where("behinderung_bis", "<=", c.zeitraum.behinderungBis.bis);
if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von);
if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis);
// Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im
// nächsten Quartal aus".
if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von);
if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis);
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
return q;
}
export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = {
eventType?: HistoryEventType;
/** Beendigungsart bzw. Art der Langzeitabwesenheit — nur mit eventType sinnvoll. */
eventSubtype?: string;
division?: string;
location?: string;
from?: string;
to?: string;
};
/**
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
*/
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
const divisionName = new Map<string, string>();
const departmentName = new Map<string, string>();
const teamName = new Map<string, string>();
for (const unit of orgMaps.unitList) {
for (const a of ancestorsOf(orgMaps, unit.id)) {
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
}
}
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
}
export async function loadOrgLookups(tx: Tx): Promise<{
lookups: OrgLookups;
orgMaps: OrgMaps;
units: UnitOption[];
locations: { id: string; name: string }[];
}> {
const orgMaps = await loadOrgMaps(tx);
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
return {
lookups: lookupsFromOrgMaps(orgMaps, locations),
orgMaps,
// Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung
// auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen.
// Der Filter greift immer auf den ganzen Teilbaum darunter.
units: orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
depth: orgMaps.depthOf.get(u.id) ?? 0,
unitType: u.unit_type,
})),
locations,
};
}
const SNAPSHOT_EMPLOYEE_COLUMNS = [
"id",
"first_name",
"last_name",
"job_title",
"location_id",
"employment_type",
"contract_type",
"entry_date",
"exit_date",
"weekly_hours",
"source",
"paygrade",
"birth_date",
"gender",
"karenz_start_date",
"karenz_return_date",
"worker_type",
"collective_agreement",
"work_days",
"is_betriebsrat",
"has_dienstwagen",
"is_laterale_fuehrung",
"is_c_level",
"teilzeit_art",
] as const;
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
// Zeilen holt und sie selbst durchgeht.
const rows = await tx
.selectFrom("employee_dependents")
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
.groupBy("employee_id")
.execute();
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
}
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso();
function snapshotQuery() {
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
snapshotQuery().execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf }),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
// Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) {
const placement = placements.get(e.id);
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
// sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue;
withDerivedStatus.push({
id: e.id,
first_name: e.first_name,
last_name: e.last_name,
job_title: placement?.jobTitle ?? e.job_title,
org_unit_id: orgUnitId,
location_id: e.location_id,
status: deriveStatusAsOf(e, asOf),
employment_type: e.employment_type,
contract_type: e.contract_type,
entry_date: e.entry_date,
exit_date: e.exit_date,
weekly_hours: e.weekly_hours,
source: e.source,
paygrade: e.paygrade,
birth_date: e.birth_date,
gender: e.gender,
worker_type: e.worker_type,
collective_agreement: e.collective_agreement,
work_days: e.work_days,
is_betriebsrat: e.is_betriebsrat,
has_dienstwagen: e.has_dienstwagen,
is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level,
teilzeit_art: e.teilzeit_art,
dependents_count: dependentsCounts.get(e.id) ?? 0,
});
}
const statuses = parseStatuses(filters.status);
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
}
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
// Team stand, in das die Person nie versetzt worden war.
//
// from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
const currentYear = new Date().getFullYear();
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
function historyQuery() {
let q = tx
.selectFrom("employee_history")
.select(["employee_id", "event_date", "event_type", "description"])
.orderBy("id");
if (from) q = q.where("event_date", ">=", from);
if (to) q = q.where("event_date", "<=", to);
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
return q;
}
const [history, employees, assignments, orgMaps] = await Promise.all([
historyQuery().execute(),
tx
.selectFrom("employees")
// exit_reason/absence_type für den Untertyp eines Ereignisses — siehe
// ReportEvent.subtype in lib/reports.ts, dort steht auch, wo die Grenze
// dieser Zuordnung liegt.
.select(["id", "first_name", "last_name", "job_title", "location_id", "exit_reason", "absence_type"])
.orderBy("id")
.execute(),
tx
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
.orderBy("a.employee_id")
.execute(),
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments) {
const list = spans.get(a.employee_id) ?? [];
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
spans.set(a.employee_id, list);
}
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const employeeById = new Map(employees.map((e) => [e.id, e]));
const events: ReportEvent[] = [];
for (const h of history) {
const emp = employeeById.get(h.employee_id);
if (!emp) continue;
if (filters.location && emp.location_id !== filters.location) continue;
const unitId =
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
const subtype = untertypVon(h.event_type, emp);
// Der Untertyp schränkt nur innerhalb seines Ereignistyps ein. Ohne die
// erste Hälfte fielen bei „Alle Ereignistypen" sämtliche Zeilen ohne
// Untertyp heraus — eine Versetzung hat keine Beendigungsart.
if (filters.eventSubtype && EREIGNIS_UNTERTYP[h.event_type] && subtype !== filters.eventSubtype) continue;
events.push({
employee_id: emp.id,
first_name: emp.first_name,
last_name: emp.last_name,
job_title: emp.job_title,
org_unit_id: unitId,
location_id: emp.location_id,
event_date: h.event_date,
event_type: h.event_type,
description: h.description,
subtype,
});
}
return events;
}
/**
* Der Untertyp eines Ereignisses, gelesen von der Person.
*
* `Rückkehr` erbt die Art der Abwesenheit bewusst nicht: sie wird beim
* Zurückkommen zurückgesetzt, und was dort heute steht, gehört zur nächsten
* Abwesenheit, nicht zur beendeten.
*/
function untertypVon(
typ: HistoryEventType,
emp: { exit_reason: string | null; absence_type: string | null }
): string | null {
if (typ === "Austritt") return emp.exit_reason;
if (typ === "Karenz") return emp.absence_type;
return null;
}