Files
alpenwerk-hr/db/migrations/20260917100000_austrittsart.sql
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

232 lines
11 KiB
PL/PgSQL

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