Let a planned absence be called off

The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss
über den Vorgang selbst abgebrochen werden." There was no such way. The
row sat in the file, the scheduled change kept running toward its date,
and nothing could stop either one.

That is not hypothetical. One person went absent in July, came back in
August, and still has a second return booked for the first of September
— recorded while they were already working again. The guard added
yesterday stops a third from being written; it does not remove the one
that exists.

Absences are called off whole, not field by field. For a planned
contract change the scheduled payload gets the affected fields lifted
out of it and runs on with the rest; an absence has no fields in that
map, and half an absence is not a thing anyone means. So the whole
scheduled change is cancelled, and what it had already noted on the
person goes with it: the date they were to be away from, the date they
were to come back on. Left behind, the profile would show an absence
with no event behind it. If the absence is still running, the return
date planned when it began applies again.

The link between the row and the scheduled change had to exist first —
start_karenz and record_karenz_return now record it. Existing rows get
it backfilled, but only where one running change of that kind falls on
that person and that day. Where two would match, the row keeps refusing:
guessing which process to cancel is worse than refusing to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 11:44:21 +02:00
parent 861c47b757
commit 16216c5537
5 changed files with 525 additions and 15 deletions

View File

@@ -41,6 +41,10 @@ export function HistorieLoeschen({
const zurueck = vorschau.filter((v) => !v.bleibt); const zurueck = vorschau.filter((v) => !v.bleibt);
const bleibt = vorschau.filter((v) => v.bleibt); const bleibt = vorschau.filter((v) => v.bleibt);
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
// sich herausnehmen liessen — sie fällt als Ganzes. Dann ist „Löschen und
// zurücksetzen" das falsche Wort für das, was der Knopf tut.
const ganzerVorgang = istZukunft && vorschau.length === 0;
async function loeschen() { async function loeschen() {
setLaeuft(true); setLaeuft(true);
@@ -70,14 +74,14 @@ export function HistorieLoeschen({
<Modal <Modal
open={offen} open={offen}
onClose={() => setOffen(false)} onClose={() => setOffen(false)}
title="Eintrag löschen" title={ganzerVorgang ? "Geplanten Vorgang abbrechen" : "Eintrag löschen"}
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setOffen(false)}> <Button variant="ghost" onClick={() => setOffen(false)}>
Abbrechen Abbrechen
</Button> </Button>
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110"> <Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
Löschen und zurücksetzen {ganzerVorgang ? "Vorgang abbrechen" : "Löschen und zurücksetzen"}
</Button> </Button>
</> </>
} }
@@ -87,7 +91,20 @@ export function HistorieLoeschen({
Historie entfernt. Historie entfernt.
</p> </p>
{istZukunft && ( {ganzerVorgang && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<p className="mt-1.5 text-sm text-ink-body">
Der Vorgang entfällt ganz — die {bezeichnung} zum {fmtDate(datum)} findet nicht statt. Am Stammsatz wird
das vorgemerkte Datum mit entfernt.
</p>
<p className="mt-2 text-xs text-ink-muted">
An den heutigen Stammdaten ändert sich nichts: der Vorgang war noch nicht wirksam.
</p>
</div>
)}
{istZukunft && vorschau.length > 0 && (
<div className="mt-4"> <div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3> <h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<ul className="mt-1.5 flex flex-col gap-1"> <ul className="mt-1.5 flex flex-col gap-1">

View File

@@ -28,8 +28,13 @@ type Eintrag = {
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */ /** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"]; const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
/** Abwesenheit und Rückkehr haben eigene Vorgänge, auch für die Zukunft. */ /**
const NUR_WIRKSAM: HistoryEventType[] = ["Karenz", "Rückkehr"]; * Abwesenheit und Rückkehr werden anders zurückgenommen als der Rest: aus
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
*/
const OHNE_FELDWERTE: HistoryEventType[] = ["Karenz", "Rückkehr"];
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil { export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") { if (eintrag.event_type === "Eintritt") {
@@ -56,13 +61,6 @@ export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eint
}; };
} }
if (NUR_WIRKSAM.includes(eintrag.event_type) && eintrag.event_date > heute) {
return {
erlaubt: false,
grund: "Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden.",
};
}
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten // Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie // Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
// liessen sich nur über Person und Datum zuordnen, und das ist nicht // liessen sich nur über Person und Datum zuordnen, und das ist nicht
@@ -76,6 +74,11 @@ export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eint
}; };
} }
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
return { erlaubt: true };
}
if (!eintrag.changes || eintrag.changes.length === 0) { if (!eintrag.changes || eintrag.changes.length === 0) {
return { return {
erlaubt: false, erlaubt: false,

View File

@@ -0,0 +1,430 @@
-- Eine geplante Abwesenheit oder Rückkehr zurücknehmen
--
-- Bisher wies das Löschen sie ab: „Sie muss über den Vorgang selbst
-- abgebrochen werden." Nur gab es diesen Weg nirgends — die Zeile stand in
-- der Akte, der Vorgang lief weiter, und niemand konnte beides aufhalten.
-- Genau dieser Fall steht in den Daten: eine Person, die im Juli abwesend
-- wurde, im August zurückkam und für die trotzdem noch eine zweite Rückkehr
-- zum 1. September vorgemerkt ist.
--
-- Drei Teile:
-- 1. start_karenz und record_karenz_return vermerken den geplanten Vorgang
-- an der Historienzeile. Ohne diesen Verweis liesse sich nur über Person
-- und Datum raten, welcher Vorgang gemeint ist.
-- 2. Die vorhandenen Zeilen bekommen den Verweis nachgetragen, aber nur wo
-- er eindeutig ist.
-- 3. delete_history_entry bricht den Vorgang ab, statt abzuweisen.
CREATE OR REPLACE FUNCTION public.start_karenz(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_start_date date := (payload->>'karenz_start_date')::date;
v_absence_type text := nullif(payload->>'absence_type', '');
v_name text;
v_old employees%rowtype;
-- Ohne Vorher-Werte liesse sich eine irrtümlich erfasste Abwesenheit
-- nicht zurücknehmen: es stünde nirgends, was vorher galt.
v_changes jsonb := '[]'::jsonb;
-- Der geplante Vorgang, damit sich die Zeile in der Historie auf ihn
-- beziehen kann: ohne diesen Verweis liesse sich eine irrtümlich
-- erfasste, noch nicht wirksame Abwesenheit nicht mehr abbrechen.
v_plan_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text,
case when v_start_date <= current_date then 'Karenz' else v_old.status::text end);
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, v_absence_type);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, v_start_date::text);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, payload->>'planned_return_date');
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date,
absence_type = v_absence_type
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type))
returning id into v_plan_id;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_start_date, 'Karenz',
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end,
v_changes, v_plan_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Karenz', v_name, v_employee_id,
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$function$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
v_old employees%rowtype;
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
v_changes jsonb := '[]'::jsonb;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
-- Wie bei der Abwesenheit: der Verweis auf den geplanten Vorgang.
v_plan_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_karenz_start := v_old.karenz_start_date;
v_absence_type := v_old.absence_type;
-- Ohne Abwesenheit keine Rückkehr. Die Prüfung fehlte, und in den Daten
-- steht eine Person mit zwei Rückkehren zu einer Abwesenheit: die zweite
-- wurde erfasst, als sie längst wieder aktiv war. Der Status wäre danach
-- aus einem Ereignis abgeleitet, das nie stattgefunden hat.
if v_old.status <> 'Karenz' and v_old.karenz_start_date is null then
raise exception 'Diese Person ist nicht abwesend — eine Rückkehr gibt es nur aus einer Abwesenheit.';
end if;
-- Und nur eine: eine zweite geplante Rückkehr würde die erste am
-- Stichtag stillschweigend überschreiben.
if exists (
select 1 from pending_org_changes p
where p.employee_id = v_employee_id and p.change_type = 'karenz_return' and p.status = 'pending'
) then
raise exception 'Für diese Person ist bereits eine Rückkehr geplant. Sie muss zuerst zurückgenommen werden.';
end if;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')))
returning id into v_plan_id;
end if;
if v_return_date <= current_date then
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
if v_employment_type is not null then
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
end if;
if payload->>'employment_mode' = 'Teilzeit' then
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
end if;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end,
v_changes, v_plan_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
-- Nachtrag für die Zeilen, die vor dieser Verknüpfung entstanden sind.
-- Nur wo genau ein laufender Vorgang derselben Person auf denselben Tag
-- fällt: bei zweien wäre die Zuordnung geraten, und geraten wird hier nicht.
update employee_history h
set pending_id = p.id
from pending_org_changes p
where h.pending_id is null
and h.event_type in ('Karenz', 'Rückkehr')
and p.employee_id = h.employee_id
and p.status = 'pending'
and p.effective_date = h.event_date
and p.change_type = case h.event_type when 'Karenz' then 'karenz_start' else 'karenz_return' end
and (select count(*) from pending_org_changes q
where q.employee_id = h.employee_id
and q.status = 'pending'
and q.effective_date = h.event_date
and q.change_type = p.change_type) = 1;
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_gruppe text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_leer boolean;
-- Das Rückkehrdatum, das beim Beginn der Abwesenheit vorgesehen war.
v_geplant date;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht zurücknehmen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
-- Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
-- Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
-- Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus
-- einem Eintrag, dessen Ausgangslage gelöscht ist.
if v_eintrag.event_type = 'Karenz' and exists (
select 1 from employee_history h
where h.employee_id = v_eintrag.employee_id
and h.event_type = 'Rückkehr'
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) then
raise exception 'Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.';
end if;
-- ── Geplante Abwesenheit oder Rückkehr: ganz abbrechen ────────────
--
-- Für die übrigen Vorgänge wird aus dem geplanten Vorgang Feld für Feld
-- herausgenommen, was die Zeile beschreibt. Abwesenheit und Rückkehr
-- gehen so nicht: ihre Felder haben in app_feld_karte keinen Ort, und
-- eine halbe Abwesenheit gibt es fachlich auch nicht. Hier fällt der
-- Vorgang deshalb ganz — was genau die Frage ist, die jemand stellt,
-- der die geplante Zeile löscht.
--
-- Was am Stammsatz schon vermerkt war, geht mit: das Datum, ab dem
-- jemand fehlen sollte, und das Datum, an dem er zurückkommen sollte.
-- Bliebe es stehen, stünde im Profil eine Abwesenheit ohne Ereignis.
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Abwesenheit ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
if v_plan.change_type = 'karenz_start' then
-- Die Abwesenheit hat nie begonnen; sie kann es auch nicht mehr.
update employees set karenz_start_date = null, karenz_return_date = null
where id = v_eintrag.employee_id and status <> 'Karenz';
else
-- Läuft die Abwesenheit noch, gilt wieder das Datum, das bei ihrem
-- Beginn vorgesehen war. Ist die Person längst zurück, war die
-- geplante Rückkehr ohnehin gegenstandslos.
select nullif(a->>'nachher', '')::date into v_geplant
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.event_type = 'Karenz'
and a->>'feld' = 'Geplante Rückkehr'
order by h.event_date desc, h.created_at desc
limit 1;
update employees
set karenz_return_date = case when status = 'Karenz' or karenz_start_date is not null then v_geplant end
where id = v_eintrag.employee_id;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen (der Vorgang entfällt ganz)',
coalesce(v_eintrag.changes, '[]'::jsonb));
return;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
if v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := v_plan.payload;
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_gruppe := v_karte->v_feld->>2;
if v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
end if;
end loop;
v_leer := coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
if v_leer then
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
else
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
v_eintrag.changes);
return;
end if;
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;

View File

@@ -303,3 +303,37 @@ describe("HistorieTab — Filter", () => {
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
}); });
}); });
// Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die
// sich herausnehmen liessen — sie fällt als Ganzes. Der Dialog muss das sagen,
// sonst steht unter „Wird nicht mehr passieren" eine leere Liste.
describe("HistorieTab — geplanten Vorgang abbrechen", () => {
const GEPLANT = eintrag({
event_type: "Rückkehr",
event_date: "2099-09-01",
description: "Rückkehr aus Elternkarenz am 2099-09-01",
changes: null,
pending_id: "p1",
});
it("bietet das Abbrechen an", () => {
render(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
expect(screen.getByRole("button", { name: /Rückkehr vom 01\.09\.2099 löschen/ })).toBeInTheDocument();
});
it("nennt den Knopf beim richtigen Namen und sagt, was entfällt", async () => {
const user = userEvent.setup();
render(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: /löschen/ }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(/Der Vorgang entfällt ganz/)).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Vorgang abbrechen" })).toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: "Löschen und zurücksetzen" })).not.toBeInTheDocument();
});
it("bietet es ohne hinterlegten Vorgang nicht an", () => {
render(<HistorieTab history={[eintrag({ ...GEPLANT, pending_id: null })]} employeeId="e1" />);
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
});
});

View File

@@ -149,10 +149,36 @@ describe("Reihenfolge von Abwesenheit und Rückkehr", () => {
expect(darfKorrigiertWerden(karenz, HEUTE, [frueher, karenz]).erlaubt).toBe(true); expect(darfKorrigiertWerden(karenz, HEUTE, [frueher, karenz]).erlaubt).toBe(true);
}); });
it("lässt eine noch nicht wirksame Abwesenheit in Ruhe", () => { it("bricht eine noch nicht wirksame Abwesenheit ab, wenn ihr Vorgang bekannt ist", () => {
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1" }); // Vorher hiess es hier „Sie muss über den Vorgang selbst abgebrochen
// werden" — nur gab es diesen Weg nirgends. Die Zeile stand in der Akte,
// der Vorgang lief weiter, und niemand konnte beides aufhalten.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
expect(darfKorrigiertWerden(geplant, HEUTE, [geplant]).erlaubt).toBe(true);
});
it("bleibt bei einer geplanten Abwesenheit ohne hinterlegten Vorgang stehen", () => {
// Ohne Verweis liesse sich nur über Person und Datum raten, welcher
// Vorgang gemeint ist — und geraten wird beim Abbrechen nicht.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: null, changes: null });
const u = darfKorrigiertWerden(geplant, HEUTE, [geplant]); const u = darfKorrigiertWerden(geplant, HEUTE, [geplant]);
expect(u.erlaubt).toBe(false); expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/); expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("verlangt bei einer bereits wirksamen Abwesenheit weiterhin Feldwerte", () => {
// Rückwärts geht es nur, wenn irgendwo steht, was vorher galt.
const alt = eintrag({ event_type: "Karenz", event_date: "2026-07-15", changes: null });
const u = darfKorrigiertWerden(alt, HEUTE, [alt]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
});
it("hält die Reihenfolge auch bei geplanten Einträgen ein", () => {
const geplanteKarenz = eintrag({ id: "k2", event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
const geplanteRueckkehr = eintrag({ id: "r2", event_type: "Rückkehr", event_date: "2099-06-01", pending_id: "p2", changes: null });
const u = darfKorrigiertWerden(geplanteKarenz, HEUTE, [geplanteKarenz, geplanteRueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/zuerst gelöscht/);
}); });
}); });