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.
This commit is contained in:
2026-09-17 12:04:53 +02:00
parent 4165f3f8a1
commit 9ded472d25
8 changed files with 342 additions and 120 deletions

View File

@@ -1,86 +1,59 @@
import type { Austrittsart } from "./types";
// Wie ein Dienstverhältnis endet — und auf wessen Betreiben.
//
// Die Liste stand bisher in components/employees/panels/TerminatePanel.tsx und
// damit an genau einer Stelle: dem Formular. Der Berichtemanager konnte
// deshalb nach dem Ereignis „Austritt" fragen, aber nicht danach, *warum* —
// und die Frage, die im Personalwesen zuerst kommt, ist die nach der
// Fluktuation: wie viele sind gegangen, wie vielen wurde gekündigt.
// Zwei Angaben, die **unabhängig** voneinander erhoben werden. Das ist die
// Korrektur einer früheren Entscheidung, und der Grund gehört hierher, damit
// sie niemand versehentlich rückgängig macht:
//
// ── Warum die Zuordnung abgeleitet und nicht gespeichert wird ────────
// Wir hatten „freiwillig / unfreiwillig" aus der Beendigungsart abgeleitet —
// Kündigung AN gilt als freiwillig, Kündigung AG als unfreiwillig, und so
// fort. Das schien sauberer, weil es zwei Felder ausschliesst, die einander
// widersprechen können.
//
// Aus dem Workshop kam „ein weiteres Dropdown: freiwilliger / unfreiwilliger
// Austritt". Als zweites, frei wählbares Feld liesse es sich dem Grund
// widersprechen — „Entlassung, freiwillig" wäre eine erfassbare Eingabe, und
// zwei Auswertungen über dieselbe Person kämen zu verschiedenen Ergebnissen.
// Auf wessen Betreiben beendet wird, steckt bereits in der Beendigungsart;
// das Dropdown schränkt deshalb die Auswahl ein, statt eine zweite Wahrheit
// daneben zu stellen. Gespeichert wird weiterhin nur `exit_reason`.
// In Österreich stimmt es nicht. Der Kunde hat es am 17.09.2026 am Beispiel
// erklärt: die einvernehmliche Auflösung ist hier der Regelfall und sagt über
// den Anstoss nichts aus. Sie kann von der Person ausgehen — „ich möchte
// kündigen", worauf einvernehmlich aufgelöst wird, damit das AMS zahlt. Sie
// kann vom Dienstgeber ausgehen — „ich will die Trennung, dafür gibt es eine
// Abfindung". Dieselbe Beendigungsart, zwei gegensätzliche Antworten.
//
// ── Warum es drei Gruppen sind und nicht zwei ────────────────────────
// Was sich nicht aus den Daten ergibt, muss erhoben werden. Die Ableitung
// hätte die Hälfte der Fälle still falsch einsortiert.
//
// Zwei Gruppen decken die Liste nicht ab. Ein Befristungsablauf geschieht auf
// niemandes Betreiben — er war von Anfang an vereinbart. Ein Nichtantritt ist
// kein Austritt, sondern ein Verhältnis, das nie begonnen hat. Beide einer der
// beiden Seiten zuzuschlagen würde jede Fluktuationsquote verfälschen, und
// zwar in eine Richtung, die niemand mehr nachvollziehen kann. Sie stehen
// deshalb als „Sonstige" für sich.
//
// Offen und im Begleitschreiben an den Kunden gestellt: ob die
// einvernehmliche Auflösung — hier als freiwillig geführt, weil sie die
// Zustimmung der Person voraussetzt — dort richtig steht. In der Praxis geht
// ihr oft ein Wunsch des Dienstgebers voraus.
// Deshalb gibt es hier auch keine Zuordnung mehr von Art zu Grund und keine
// eingeschränkte Auswahl: die Beendigungsart bietet immer alle Werte an.
export const BEENDIGUNG_NO_SHOW = "No Show";
export type Austrittsart = "freiwillig" | "unfreiwillig" | "sonstige";
/**
* Die Beendigungsarten.
*
* „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
* Sinn, sondern der Fall, dass jemand nie angetreten ist.
*/
export const BEENDIGUNGSART_WERTE: readonly string[] = [
"Einvernehmliche Auflösung",
"Kündigung AN",
"Kündigung AG",
"Befristungsablauf",
"Pensionierung",
"Entlassung",
// Aus dem Workshop nachgetragen: in der Probezeit kann jede Seite ohne
// Frist und ohne Grund beenden; welche es war, ist die Unterscheidung, die
// sonst verlorenginge.
"Dienstnehmerseitige Beendigung in Probezeit",
"Dienstgeberseitige Beendigung in Probezeit",
BEENDIGUNG_NO_SHOW,
] as const;
export const AUSTRITTSART_LABELS: Record<Austrittsart, string> = {
freiwillig: "Freiwilliger Austritt",
unfreiwillig: "Unfreiwilliger Austritt",
sonstige: "Sonstige Beendigung",
};
export type Beendigungsart = {
/** Der gespeicherte Wert in `employees.exit_reason`. */
wert: string;
art: Austrittsart;
};
export const AUSTRITTSARTEN: readonly Austrittsart[] = ["freiwillig", "unfreiwillig"] as const;
export const BEENDIGUNGSARTEN: readonly Beendigungsart[] = [
{ wert: "Einvernehmliche Auflösung", art: "freiwillig" },
{ wert: "Kündigung AN", art: "freiwillig" },
{ wert: "Pensionierung", art: "freiwillig" },
// Aus dem Workshop nachgetragen (Anforderung 1). In der Probezeit kann jede
// Seite ohne Frist und ohne Grund beenden; welche es war, ist genau die
// Unterscheidung, die sonst verlorenginge.
{ wert: "Dienstnehmerseitige Beendigung in Probezeit", art: "freiwillig" },
{ wert: "Kündigung AG", art: "unfreiwillig" },
{ wert: "Entlassung", art: "unfreiwillig" },
{ wert: "Dienstgeberseitige Beendigung in Probezeit", art: "unfreiwillig" },
{ wert: "Befristungsablauf", art: "sonstige" },
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
{ wert: BEENDIGUNG_NO_SHOW, art: "sonstige" },
] as const;
export const BEENDIGUNGSART_WERTE: readonly string[] = BEENDIGUNGSARTEN.map((b) => b.wert);
const ART_ZU_WERT = new Map(BEENDIGUNGSARTEN.map((b) => [b.wert, b.art] as const));
/**
* Auf wessen Betreiben beendet wurde — oder null, wenn der gespeicherte Grund
* in keiner der Gruppen steht.
*
* Null ist kein Sonderfall, sondern der Normalfall für Altdaten: `exit_reason`
* ist eine Textspalte ohne Prüfung, und der Massenimport schreibt hinein, was
* in der Datei stand. Wer hier auf eine Gruppe rät, ordnet fremde Schreibweisen
* still der falschen zu.
*/
export function austrittsartVon(grund: string | null | undefined): Austrittsart | null {
return grund ? (ART_ZU_WERT.get(grund) ?? null) : null;
}
/** Die Beendigungsarten einer Gruppe — für die abhängige Auswahl im Formular. */
export function beendigungsartenZu(art: Austrittsart | null): readonly string[] {
return art ? BEENDIGUNGSART_WERTE.filter((w) => ART_ZU_WERT.get(w) === art) : BEENDIGUNGSART_WERTE;
export function istAustrittsart(wert: string | null | undefined): wert is Austrittsart {
return AUSTRITTSARTEN.includes(wert as Austrittsart);
}

View File

@@ -1,5 +1,5 @@
import { ABSENCE_TYPES } from "./absence";
import { AUSTRITTSART_LABELS, BEENDIGUNGSART_WERTE, type Austrittsart } from "./beendigung";
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart";
import { parseIsoDateParam } from "./reports";
@@ -72,11 +72,14 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
// Grund. Die Austrittsart (freiwillig/unfreiwillig) steht daneben und wird
// aus derselben Angabe abgeleitet, nicht zweitgespeichert.
{ key: "beendigung", label: "Beendigungsart", alle: "Alle Beendigungsarten", optionen: werte(...BEENDIGUNGSART_WERTE) },
// Eigene Spalte, nicht aus der Beendigungsart abgeleitet — siehe
// lib/beendigung.ts. Der Wert steht in der Datenbank genauso da; nur die
// Beschriftung kommt von hier.
{
key: "austrittsart",
label: "Austrittsart",
label: "Freiwillig oder unfreiwillig",
alle: "Alle Austrittsarten",
optionen: (Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ wert: a, label: AUSTRITTSART_LABELS[a] })),
optionen: AUSTRITTSARTEN.map((a) => ({ wert: a, label: AUSTRITTSART_LABELS[a] })),
},
{
key: "teilzeitArt",

View File

@@ -3,7 +3,7 @@ import type { Schema } from "./db/schema";
import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import { beendigungsartenZu, type Austrittsart } from "./beendigung";
import type { Austrittsart } from "./types";
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import {
deriveStatusAsOf,
@@ -73,14 +73,10 @@ export function applyCriteria<O>(
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);
// Die Austrittsart ist keine Spalte, sondern eine Einteilung der
// Beendigungsarten (lib/beendigung.ts). Aus ihr wird deshalb eine
// Wertemenge statt eines Vergleichs — dieselbe Zuordnung, die auch das
// Formular verwendet, nur als `in`.
if (a.austrittsart) {
const werte = beendigungsartenZu(a.austrittsart as Austrittsart);
q = werte.length > 0 ? q.where("exit_reason", "in", werte) : q;
}
// 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");

View File

@@ -39,6 +39,16 @@ export type DienstwagenArt = "Verbrenner" | "Elektro";
*/
export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit";
/**
* Auf wessen Betreiben ein Dienstverhältnis endet.
*
* Eigene Angabe und **nicht** aus der Beendigungsart abgeleitet: die in
* Österreich übliche einvernehmliche Auflösung kann von beiden Seiten
* ausgehen und sagt über den Anstoss nichts aus. Die Begründung steht
* ausführlich in lib/beendigung.ts und in Migration 20260917100000.
*/
export type Austrittsart = "freiwillig" | "unfreiwillig";
/**
* Form der Beschäftigung — zweite Achse neben `worker_type`.
*
@@ -208,6 +218,8 @@ export type Database = {
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
/** Freiwillig oder unfreiwillig — eigene Angabe, nur mit einem Austritt erlaubt. */
austrittsart: Austrittsart | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
@@ -283,6 +295,7 @@ export type Database = {
entry_date: string;
exit_date?: string | null;
exit_reason?: string | null;
austrittsart?: Austrittsart | null;
karenz_start_date?: string | null;
karenz_return_date?: string | null;
absence_type?: string | null;