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

@@ -138,6 +138,13 @@ export async function terminateEmployee(payload: {
employee_id: string; employee_id: string;
exit_date: string; exit_date: string;
exit_reason: string; exit_reason: string;
/**
* Freiwillig oder unfreiwillig — leer heisst „nicht erfasst".
*
* Eigene Angabe und nicht aus `exit_reason` abgeleitet: die einvernehmliche
* Auflösung kann von beiden Seiten ausgehen (siehe lib/beendigung.ts).
*/
austrittsart?: string;
note?: string; note?: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie // No Show ist kein Austritt im gewohnten Sinn — die Person hat nie

View File

@@ -189,6 +189,9 @@ function employeeExportColumns(
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" }, { header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" }, { header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
{ header: "Austrittsgrund", get: (e) => e.exit_reason }, { 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: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_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 }, { header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },

View File

@@ -7,15 +7,8 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field"; import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
AUSTRITTSART_LABELS, import type { Austrittsart, Database, EmploymentStatus } from "@/lib/types";
BEENDIGUNG_NO_SHOW,
BEENDIGUNGSART_WERTE,
austrittsartVon,
beendigungsartenZu,
type Austrittsart,
} from "@/lib/beendigung";
import type { Database, EmploymentStatus } from "@/lib/types";
import { fmtDate, fmtName } from "@/lib/format"; import { fmtDate, fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -41,11 +34,9 @@ export function TerminatePanel({ open, onClose, employee, status, directReportCo
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die // aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
// übrigen zu verstellen. // übrigen zu verstellen.
const [reason, setReason] = useState(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]); const [reason, setReason] = useState(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
// „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim // Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
// Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt, // Seite wäre eine Behauptung, die niemand getroffen hat.
// soll sie unmittelbar wählen können.
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">(""); const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
const waehlbar = beendigungsartenZu(austrittsart || null);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@@ -66,6 +57,9 @@ export function TerminatePanel({ open, onClose, employee, status, directReportCo
employee_id: employee.id, employee_id: employee.id,
exit_date: wirksamesDatum, exit_date: wirksamesDatum,
exit_reason: reason, exit_reason: reason,
// Leer statt undefined: die SQL-Funktion macht daraus null, und null
// heisst „nicht erfasst" — das ist der ehrliche Wert.
austrittsart,
note, note,
}); });
setPending(false); setPending(false);
@@ -120,36 +114,38 @@ export function TerminatePanel({ open, onClose, employee, status, directReportCo
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet. {directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div> </div>
)} )}
{/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird {/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
allein die Beendigungsart; auf wessen Betreiben beendet wurde, Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse vollständig und keine schränkt die andere ein.
sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über
dieselbe Person kämen zu verschiedenen Ergebnissen. */} Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
<SelectField sie filterte die Beendigungsarten darunter, weil der Anstoss aus
label="Austrittsart" der Art ableitbar schien. In Österreich ist er das nicht — die
value={austrittsart} einvernehmliche Auflösung ist der Regelfall und kann von beiden
onChange={(v) => { Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
const neu = (v || "") as Austrittsart | "";
setAustrittsart(neu); Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
// Passt der bisher gewählte Grund nicht mehr zur Einschränkung, als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
// wandert die Auswahl auf den ersten passenden. Stehenzulassen, Formulars; er ist weg. */}
// was in der Liste nicht mehr sichtbar ist, wäre ein Formular,
// das etwas anderes absendet, als es zeigt.
const erlaubt = beendigungsartenZu(neu || null);
if (!erlaubt.includes(reason)) setReason(erlaubt[0]);
}}
options={[
{ value: "", label: "Alle Beendigungsarten" },
...(Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
]}
hint="Schränkt die Auswahl darunter ein. Gespeichert wird die Beendigungsart."
/>
<SelectField <SelectField
label="Beendigungsart" label="Beendigungsart"
value={reason} value={reason}
onChange={setReason} onChange={setReason}
options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))} options={BEENDIGUNGSART_WERTE.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined} />
<SelectField
label="Freiwillig oder unfreiwillig"
value={austrittsart}
onChange={(v) => setAustrittsart((v || "") as Austrittsart | "")}
options={[
// „Nicht erfasst" als Vorbelegung und nicht eine der beiden
// Seiten: ein Befristungsablauf geschieht auf niemandes
// Betreiben, und ein Nichtantritt ist gar kein Austritt. Eine
// erzwungene Antwort wäre dort eine erfundene Zahl.
{ value: "", label: "Nicht erfasst" },
...AUSTRITTSARTEN.map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
]}
hint="Für die Fluktuationsauswertung. Lässt sich aus der Beendigungsart nicht ableiten."
/> />
<TextField <TextField
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"} label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}

View File

@@ -0,0 +1,231 @@
-- Freiwillig oder unfreiwillig — als eigene Angabe, nicht als Ableitung.
--
-- Aus dem Gespräch mit dem Kunden vom 17.09.2026.
--
-- ═══ Warum die Ableitung zurückgenommen wird ═══════════════════════
--
-- Wir hatten das 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.
--
-- In Österreich stimmt es nur nicht. Der Kunde hat es am Beispiel erklärt:
-- die einvernehmliche Auflösung ist hier der Regelfall, und sie sagt über
-- den Anstoss nichts aus. Sie kann von der Person ausgehen — „ich möchte
-- kündigen", worauf der Dienstgeber einvernehmlich auflöst, damit das AMS
-- zahlt. Sie kann vom Dienstgeber ausgehen — „ich will die Trennung, dafür
-- gibt es eine Abfindung", worauf beide einvernehmlich unterschreiben, weil
-- das rechtlich besser abgesichert ist. Dieselbe Beendigungsart, zwei
-- gegensätzliche Antworten, und die Unterscheidung ist genau die, auf die es
-- bei einer Fluktuationsanalyse ankommt.
--
-- Was sich nicht aus den Daten ergibt, muss erhoben werden. Die Ableitung
-- hätte die Hälfte der Fälle still falsch einsortiert — und zwar ohne dass
-- irgendwo sichtbar geworden wäre, dass hier geraten wird.
--
-- ═══ Warum die Angabe freiwillig ist ═══════════════════════════════
--
-- Nullable, und in der Oberfläche mit „Nicht erfasst" als Vorbelegung:
--
-- * Der Bestand hat sie nicht. Achthundert Zeilen nachträglich zu
-- beantworten kann niemand, und eine Pflichtspalte hätte jede Änderung
-- an einer ausgetretenen Person blockiert.
-- * Es gibt Beendigungen ohne Anstoss. Ein Befristungsablauf war von
-- Anfang an vereinbart, ein Nichtantritt ist gar kein Austritt. Dort
-- eine der beiden Seiten einzutragen wäre eine erfundene Zahl.
--
-- Ob sie für gewöhnliche Austritte zur Pflicht werden soll, ist eine Frage
-- an den Kunden und steht in docs/rueckfragen-workshop-2026-09.md.
alter table employees
add column if not exists austrittsart text;
comment on column employees.austrittsart is
'Auf wessen Betreiben das Dienstverhältnis endet — freiwillig oder unfreiwillig. Eigene Angabe und **nicht** aus exit_reason abgeleitet: die in Österreich übliche einvernehmliche Auflösung sagt über den Anstoss nichts aus. Freiwillig, weil der Bestand sie nicht trägt und ein Befristungsablauf keinen Anstoss hat.';
alter table employees drop constraint if exists chk_austrittsart;
alter table employees add constraint chk_austrittsart
check (austrittsart is null or austrittsart in ('freiwillig', 'unfreiwillig'));
comment on constraint chk_austrittsart on employees is
'Genau die zwei Werte, die der Kunde benannt hat. CHECK auf text statt Aufzählungstyp — die Liste ist eine fachliche Einteilung und kein Schlüssel, und ein zurückgenommener Wert bliebe im Typ für immer stehen.';
-- Ohne Austritt keine Austrittsart: sonst bliebe nach einer
-- Wiedereinstellung ein Rest stehen, den niemand mehr deuten kann.
alter table employees drop constraint if exists chk_austrittsart_nur_mit_austritt;
alter table employees add constraint chk_austrittsart_nur_mit_austritt
check (austrittsart is null or exit_date is not null);
-- Die Frage „wie viele sind freiwillig gegangen" ist der Zweck der Spalte.
create index if not exists idx_employees_austrittsart
on employees (austrittsart) where austrittsart is not null;
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
v_entry date;
v_art text := nullif(payload->>'austrittsart', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, entry_date into v_name, v_entry
from employees where id = v_employee_id;
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
if v_no_show then
v_exit := v_entry;
end if;
update employees set
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
-- denn es gibt keinen Lauf, der sie später nachzieht.
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason',
austrittsart = v_art
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
if v_no_show then
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
-- Intervall. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
delete from position_assignments
where employee_id = v_employee_id and valid_to is null;
else
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt',
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')'
|| case when v_art is not null then ', ' || v_art else '' end end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id,
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt am ' || v_exit || case when v_art is not null then ', ' || v_art else '' end end);
end;
$function$;
-- Eine Wiedereinstellung räumt den Austritt weg — und damit auch die
-- Austrittsart. Ohne das bliebe an einer wieder aktiven Person stehen, sie
-- sei freiwillig gegangen, und chk_austrittsart_nur_mit_austritt wiese den
-- Vorgang ohnehin zurück, weil exit_date dabei auf null geht.
--
-- Ausgeschrieben statt über pg_get_functiondef gepatcht: die beiden
-- Umstellungen, die diese Funktion seit ihrer Entstehung erfahren hat
-- (app_current_user_id statt auth.uid, fester search_path), sind hier
-- übernommen. Wer das nachliest, sieht die ganze Funktion und nicht eine
-- Kette von Ersetzungen.
CREATE OR REPLACE FUNCTION public.rehire_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null,
austrittsart = null
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id,
'Wiedereintritt am ' || v_date);
end;
$function$;
grant execute on function public.terminate_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.rehire_employee(payload jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_term text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
v_re text := pg_get_functiondef('public.rehire_employee(jsonb)'::regprocedure);
v_bedingung text;
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employees' and column_name = 'austrittsart'
) then
raise exception 'Die Spalte austrittsart fehlt';
end if;
if v_term not like '%austrittsart%' then
raise exception 'terminate_employee schreibt die Austrittsart nicht';
end if;
if v_re not like '%austrittsart = null%' then
raise exception 'rehire_employee räumt die Austrittsart nicht weg';
end if;
-- Die beiden Umstellungen, die diese Funktion schon hinter sich hatte,
-- dürfen beim Neuschreiben nicht verlorengehen. Genau das ist der Fehler,
-- den ein „create or replace" aus einer alten Vorlage leise macht.
if v_re like '%auth.uid()%' then
raise exception 'rehire_employee ruft wieder auth.uid() — die Anwendungsrolle darf das Schema auth nicht';
end if;
if position('SET search_path' in v_re) = 0 then
raise exception 'rehire_employee hat keinen festen search_path mehr';
end if;
if position('SET search_path' in v_term) = 0 then
raise exception 'terminate_employee hat keinen festen search_path mehr';
end if;
-- Der Nichtantritt muss erhalten bleiben: er hängt an dieser Funktion und
-- wäre beim Übertragen leicht zu verlieren.
if v_term not like '%v_no_show%' or v_term not like '%delete from position_assignments%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr';
end if;
foreach v_bedingung in array array['chk_austrittsart', 'chk_austrittsart_nur_mit_austritt'] loop
if not exists (select 1 from pg_constraint where conname = v_bedingung) then
raise exception 'Die Bedingung % fehlt', v_bedingung;
end if;
end loop;
end
$$;

View File

@@ -1,86 +1,59 @@
import type { Austrittsart } from "./types";
// Wie ein Dienstverhältnis endet — und auf wessen Betreiben. // Wie ein Dienstverhältnis endet — und auf wessen Betreiben.
// //
// Die Liste stand bisher in components/employees/panels/TerminatePanel.tsx und // Zwei Angaben, die **unabhängig** voneinander erhoben werden. Das ist die
// damit an genau einer Stelle: dem Formular. Der Berichtemanager konnte // Korrektur einer früheren Entscheidung, und der Grund gehört hierher, damit
// deshalb nach dem Ereignis „Austritt" fragen, aber nicht danach, *warum* — // sie niemand versehentlich rückgängig macht:
// und die Frage, die im Personalwesen zuerst kommt, ist die nach der
// Fluktuation: wie viele sind gegangen, wie vielen wurde gekündigt.
// //
// ── 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 // In Österreich stimmt es nicht. Der Kunde hat es am 17.09.2026 am Beispiel
// Austritt". Als zweites, frei wählbares Feld liesse es sich dem Grund // erklärt: die einvernehmliche Auflösung ist hier der Regelfall und sagt über
// widersprechen — „Entlassung, freiwillig" wäre eine erfassbare Eingabe, und // den Anstoss nichts aus. Sie kann von der Person ausgehen — „ich möchte
// zwei Auswertungen über dieselbe Person kämen zu verschiedenen Ergebnissen. // kündigen", worauf einvernehmlich aufgelöst wird, damit das AMS zahlt. Sie
// Auf wessen Betreiben beendet wird, steckt bereits in der Beendigungsart; // kann vom Dienstgeber ausgehen — „ich will die Trennung, dafür gibt es eine
// das Dropdown schränkt deshalb die Auswahl ein, statt eine zweite Wahrheit // Abfindung". Dieselbe Beendigungsart, zwei gegensätzliche Antworten.
// daneben zu stellen. Gespeichert wird weiterhin nur `exit_reason`.
// //
// ── 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 // Deshalb gibt es hier auch keine Zuordnung mehr von Art zu Grund und keine
// niemandes Betreiben — er war von Anfang an vereinbart. Ein Nichtantritt ist // eingeschränkte Auswahl: die Beendigungsart bietet immer alle Werte an.
// 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.
export const BEENDIGUNG_NO_SHOW = "No Show"; 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> = { export const AUSTRITTSART_LABELS: Record<Austrittsart, string> = {
freiwillig: "Freiwilliger Austritt", freiwillig: "Freiwilliger Austritt",
unfreiwillig: "Unfreiwilliger Austritt", unfreiwillig: "Unfreiwilliger Austritt",
sonstige: "Sonstige Beendigung",
}; };
export type Beendigungsart = { export const AUSTRITTSARTEN: readonly Austrittsart[] = ["freiwillig", "unfreiwillig"] as const;
/** Der gespeicherte Wert in `employees.exit_reason`. */
wert: string;
art: Austrittsart;
};
export const BEENDIGUNGSARTEN: readonly Beendigungsart[] = [ export function istAustrittsart(wert: string | null | undefined): wert is Austrittsart {
{ wert: "Einvernehmliche Auflösung", art: "freiwillig" }, return AUSTRITTSARTEN.includes(wert as Austrittsart);
{ 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;
} }

View File

@@ -1,5 +1,5 @@
import { ABSENCE_TYPES } from "./absence"; 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 { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart"; import { MITARBEITERARTEN } from "./mitarbeiterart";
import { parseIsoDateParam } from "./reports"; import { parseIsoDateParam } from "./reports";
@@ -72,11 +72,14 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
// Grund. Die Austrittsart (freiwillig/unfreiwillig) steht daneben und wird // Grund. Die Austrittsart (freiwillig/unfreiwillig) steht daneben und wird
// aus derselben Angabe abgeleitet, nicht zweitgespeichert. // aus derselben Angabe abgeleitet, nicht zweitgespeichert.
{ key: "beendigung", label: "Beendigungsart", alle: "Alle Beendigungsarten", optionen: werte(...BEENDIGUNGSART_WERTE) }, { 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", key: "austrittsart",
label: "Austrittsart", label: "Freiwillig oder unfreiwillig",
alle: "Alle Austrittsarten", 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", key: "teilzeitArt",

View File

@@ -3,7 +3,7 @@ import type { Schema } from "./db/schema";
import type { Tx } from "./db"; import type { Tx } from "./db";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement"; 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 { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
import { import {
deriveStatusAsOf, deriveStatusAsOf,
@@ -73,14 +73,10 @@ export function applyCriteria<O>(
if (a.absence) q = q.where("absence_type", "=", a.absence); if (a.absence) q = q.where("absence_type", "=", a.absence);
if (a.schutzGrund) q = q.where("kuendigungsschutz_grund", "=", a.schutzGrund as KuendigungsschutzGrund); if (a.schutzGrund) q = q.where("kuendigungsschutz_grund", "=", a.schutzGrund as KuendigungsschutzGrund);
if (a.beendigung) q = q.where("exit_reason", "=", a.beendigung); if (a.beendigung) q = q.where("exit_reason", "=", a.beendigung);
// Die Austrittsart ist keine Spalte, sondern eine Einteilung der // Seit 20260917100000 eine eigene Spalte. Vorher wurde sie aus der
// Beendigungsarten (lib/beendigung.ts). Aus ihr wird deshalb eine // Beendigungsart abgeleitet und der Filter zu einer Wertemenge aufgelöst —
// Wertemenge statt eines Vergleichs — dieselbe Zuordnung, die auch das // das ordnete die einvernehmlichen Auflösungen still einer Seite zu.
// Formular verwendet, nur als `in`. if (a.austrittsart) q = q.where("austrittsart", "=", a.austrittsart as Austrittsart);
if (a.austrittsart) {
const werte = beendigungsartenZu(a.austrittsart as Austrittsart);
q = werte.length > 0 ? q.where("exit_reason", "in", werte) : q;
}
const j = c.jaNein; const j = c.jaNein;
if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja"); 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"; 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`. * Form der Beschäftigung — zweite Achse neben `worker_type`.
* *
@@ -208,6 +218,8 @@ export type Database = {
entry_date: string; entry_date: string;
exit_date: string | null; exit_date: string | null;
exit_reason: 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_start_date: string | null;
karenz_return_date: string | null; karenz_return_date: string | null;
absence_type: string | null; absence_type: string | null;
@@ -283,6 +295,7 @@ export type Database = {
entry_date: string; entry_date: string;
exit_date?: string | null; exit_date?: string | null;
exit_reason?: string | null; exit_reason?: string | null;
austrittsart?: Austrittsart | null;
karenz_start_date?: string | null; karenz_start_date?: string | null;
karenz_return_date?: string | null; karenz_return_date?: string | null;
absence_type?: string | null; absence_type?: string | null;