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:
@@ -138,6 +138,13 @@ export async function terminateEmployee(payload: {
|
||||
employee_id: string;
|
||||
exit_date: 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;
|
||||
}): Promise<ActionResult> {
|
||||
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
|
||||
|
||||
@@ -189,6 +189,9 @@ function employeeExportColumns(
|
||||
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
|
||||
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
||||
{ 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: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
|
||||
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
|
||||
|
||||
@@ -7,15 +7,8 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import {
|
||||
AUSTRITTSART_LABELS,
|
||||
BEENDIGUNG_NO_SHOW,
|
||||
BEENDIGUNGSART_WERTE,
|
||||
austrittsartVon,
|
||||
beendigungsartenZu,
|
||||
type Austrittsart,
|
||||
} from "@/lib/beendigung";
|
||||
import type { Database, EmploymentStatus } from "@/lib/types";
|
||||
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNG_NO_SHOW, BEENDIGUNGSART_WERTE } from "@/lib/beendigung";
|
||||
import type { Austrittsart, Database, EmploymentStatus } from "@/lib/types";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
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
|
||||
// übrigen zu verstellen.
|
||||
const [reason, setReason] = useState(status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
|
||||
// „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim
|
||||
// Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt,
|
||||
// soll sie unmittelbar wählen können.
|
||||
// Leer als Vorbelegung: die Angabe ist freiwillig, und eine vorausgewählte
|
||||
// Seite wäre eine Behauptung, die niemand getroffen hat.
|
||||
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
||||
const waehlbar = beendigungsartenZu(austrittsart || null);
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -66,6 +57,9 @@ export function TerminatePanel({ open, onClose, employee, status, directReportCo
|
||||
employee_id: employee.id,
|
||||
exit_date: wirksamesDatum,
|
||||
exit_reason: reason,
|
||||
// Leer statt undefined: die SQL-Funktion macht daraus null, und null
|
||||
// heisst „nicht erfasst" — das ist der ehrliche Wert.
|
||||
austrittsart,
|
||||
note,
|
||||
});
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
{/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird
|
||||
allein die Beendigungsart; auf wessen Betreiben beendet wurde,
|
||||
leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse
|
||||
sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über
|
||||
dieselbe Person kämen zu verschiedenen Ergebnissen. */}
|
||||
<SelectField
|
||||
label="Austrittsart"
|
||||
value={austrittsart}
|
||||
onChange={(v) => {
|
||||
const neu = (v || "") as Austrittsart | "";
|
||||
setAustrittsart(neu);
|
||||
// Passt der bisher gewählte Grund nicht mehr zur Einschränkung,
|
||||
// wandert die Auswahl auf den ersten passenden. Stehenzulassen,
|
||||
// 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."
|
||||
/>
|
||||
{/* ── Zwei Angaben, die einander nicht bestimmen ──────────────
|
||||
Erst die Beendigungsart, darunter der Anstoss. Beide Listen sind
|
||||
vollständig und keine schränkt die andere ein.
|
||||
|
||||
Der erste Entwurf hatte es umgekehrt: oben die Austrittsart, und
|
||||
sie filterte die Beendigungsarten darunter, weil der Anstoss aus
|
||||
der Art ableitbar schien. In Österreich ist er das nicht — die
|
||||
einvernehmliche Auflösung ist der Regelfall und kann von beiden
|
||||
Seiten ausgehen. Die Begründung steht in lib/beendigung.ts.
|
||||
|
||||
Unter der Beendigungsart stand ausserdem der abgeleitete Anstoss
|
||||
als Hinweis. Er erschien von selbst und sah aus wie ein Fehler des
|
||||
Formulars; er ist weg. */}
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined}
|
||||
options={BEENDIGUNGSART_WERTE.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
/>
|
||||
<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
|
||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||
|
||||
231
db/migrations/20260917100000_austrittsart.sql
Normal file
231
db/migrations/20260917100000_austrittsart.sql
Normal 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
|
||||
$$;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
13
lib/types.ts
13
lib/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user