-- 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 $$;