Let an absence be taken back, in the right order

A long-term absence recorded by mistake could only be undone by booking
a second event on top of it — leaving two entries in the file, the first
of which never happened. Karenz and Rückkehr can now be deleted and
corrected like the other entries.

For that to restore anything, the two operations first had to start
recording what they overwrote. start_karenz and record_karenz_return now
keep before/after the way change_employee_data does: status, kind of
absence, start, planned return — and for a return also employment type,
hours and the part-time variant. Without that there is nothing to revert
to, only a sentence.

The ordering rule HR asked for is enforced in the database, not just in
the UI: an absence cannot be deleted while a later return exists. A
return standing on its own would be a return from nothing, and the
person's status would derive from an entry whose starting point had been
deleted. Delete the return first and the absence frees up.

Rehearsed end to end on real data: absence recorded, return recorded on
reduced hours; deleting the absence refused; deleting the return put the
person back on Karenz with the original hours and the part-time variant
cleared; deleting the absence then put them back to Aktiv with no trace.

Rows written before today carry no before/after and stay untouchable,
with the reason they already gave. Planned absences are refused too —
they have their own operation, and their fields have no place in a
pending payload, which is why app_feld_karte carries a null group for
them rather than a plausible-looking wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:13:01 +02:00
parent f5ace8af2e
commit 384bdb4fb3
4 changed files with 688 additions and 4 deletions

View File

@@ -38,7 +38,9 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
{history.map((h) => { {history.map((h) => {
const isFuture = h.event_date > today; const isFuture = h.event_date > today;
const changes = h.changes ?? []; const changes = h.changes ?? [];
const urteil = darfKorrigiertWerden(h, today); // Die ganze Historie mitgeben: ob eine Abwesenheit gelöscht werden
// darf, hängt daran, ob es eine spätere Rückkehr gibt.
const urteil = darfKorrigiertWerden(h, today, history);
return ( return (
<li key={h.id} className="py-3"> <li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@@ -21,14 +21,22 @@ type Eintrag = {
changes: AuditChange[] | null; changes: AuditChange[] | null;
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */ /** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
pending_id?: string | null; pending_id?: string | null;
/** Für die Reihenfolge bei gleichem Datum. */
created_at?: string;
}; };
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string): KorrekturUrteil { /** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
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"];
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") { if (eintrag.event_type === "Eintritt") {
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." }; return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
} }
if (eintrag.event_type !== "Stammdatenänderung" && eintrag.event_type !== "Vertragsänderung") { if (!KORRIGIERBAR.includes(eintrag.event_type)) {
return { return {
erlaubt: false, erlaubt: false,
grund: grund:
@@ -37,6 +45,24 @@ export function darfKorrigiertWerden(eintrag: Eintrag, heute: string): Korrektur
}; };
} }
// 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 (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
return {
erlaubt: false,
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
};
}
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
@@ -60,6 +86,12 @@ export function darfKorrigiertWerden(eintrag: Eintrag, heute: string): Korrektur
return { erlaubt: true }; return { erlaubt: true };
} }
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
function spaeter(a: Eintrag, b: Eintrag): boolean {
if (a.event_date !== b.event_date) return a.event_date > b.event_date;
return (a.created_at ?? "") > (b.created_at ?? "");
}
/** /**
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht, * Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
* weil ein späterer Eintrag dasselbe Feld angefasst hat. * weil ein späterer Eintrag dasselbe Feld angefasst hat.

View File

@@ -0,0 +1,607 @@
-- Abwesenheit und Rückkehr lassen sich zurücknehmen und berichtigen.
--
-- Bisher endete beides bei Stammdaten- und Vertragsänderungen. Eine
-- versehentlich erfasste Langzeitabwesenheit liess sich nur durch eine zweite
-- Buchung wieder loswerden — und dann standen zwei Einträge in der Akte, von
-- denen der erste nie stattgefunden hat.
--
-- Damit das Zurücknehmen den Stand wirklich wiederherstellt, halten
-- start_karenz und record_karenz_return jetzt ihre Vorher-Werte fest, so wie
-- change_employee_data es tut: Status, Art der Abwesenheit, Beginn, geplante
-- Rückkehr — bei der Rückkehr zusätzlich Beschäftigungsausmaß, Stunden und
-- Teilzeitvariante. Erst damit hat die vorhandene Rücknahme etwas, worauf sie
-- zurücksetzen kann.
--
-- ═══ Die Reihenfolge ═══
--
-- 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. Deshalb: **eine Abwesenheit lässt sich erst löschen, wenn ihre
-- Rückkehr gelöscht ist.** Die Prüfung liegt in der Datenbank, nicht nur in
-- der Oberfläche.
--
-- Zeilen von vor dieser Migration tragen keine Vorher-Werte und bleiben
-- deshalb unantastbar — mit derselben Meldung wie bisher.
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
-- und Rückkehr ihre eigenen Vorgänge haben. Wer sie in einer
-- geplanten Änderung setzen wollte, hätte keinen Ort dafür — deshalb
-- weisen delete_/update_history_entry solche Einträge in der Zukunft
-- ab, statt an einer fehlenden Gruppe zu scheitern.
'Status', jsonb_build_array('status', 'employment_status', null),
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
);
$function$;
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;
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));
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes)
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);
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', '');
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;
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', '')));
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)
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);
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$;
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;
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;
-- Abwesenheit und Rückkehr haben eigene Vorgänge; in einer geplanten
-- Änderung haben ihre Felder keinen Ort (siehe app_feld_karte).
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden.';
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$;
CREATE OR REPLACE FUNCTION public.update_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_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gruppe text;
v_gueltig text;
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_war_zukunft boolean;
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 hier nicht berichtigen.';
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 berichtigen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst berichtigt werden.';
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 zu berichtigen.';
end if;
v_war_zukunft := v_eintrag.event_date > current_date;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
-- werden, und dafür gibt es die fachlichen Vorgänge.
if v_war_zukunft and v_datum <= current_date then
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
end if;
if not v_war_zukunft and v_datum > current_date then
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
if v_war_zukunft 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 berichtigen.';
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 := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
for v_alt in select * from jsonb_array_elements(v_neu) loop
v_feld := v_alt->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_gruppe := v_karte->v_feld->>2;
if not v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
end if;
v_neuer_payload := jsonb_set(
v_neuer_payload,
array[v_gruppe, v_spalte],
case
when v_alt->>'nachher' is null then 'null'::jsonb
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
else to_jsonb(v_alt->>'nachher')
end,
true);
end loop;
update pending_org_changes
set payload = v_neuer_payload, effective_date = v_datum
where id = v_plan.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 berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
return;
end if;
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
and h.event_date <= current_date
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
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 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
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 berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_start text := pg_get_functiondef('public.start_karenz(jsonb)'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if v_start not like '%description, changes%' then
raise exception 'start_karenz hält keine Vorher-Werte fest';
end if;
if v_ret not like '%description, changes%' then
raise exception 'record_karenz_return hält keine Vorher-Werte fest';
end if;
if v_del not like '%muss zuerst gelöscht werden%' then
raise exception 'Die Reihenfolgeregel fehlt';
end if;
if v_upd not like '%''Karenz'', ''Rückkehr''%' then
raise exception 'update_history_entry lässt die zwei Typen nicht zu';
end if;
if not (app_feld_karte() ? 'Art der Abwesenheit' and app_feld_karte() ? 'Status') then
raise exception 'Die Feldtabelle kennt die Felder der Abwesenheit nicht';
end if;
end
$$;

View File

@@ -30,7 +30,7 @@ describe("darfKorrigiertWerden", () => {
expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/); expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/);
}); });
it.each<HistoryEventType>(["Versetzung", "Beförderung", "Karenz", "Rückkehr", "Austritt", "Wiedereintritt", "Reorganisation"])( it.each<HistoryEventType>(["Versetzung", "Beförderung", "Austritt", "Wiedereintritt", "Reorganisation"])(
"verweist bei %s auf den fachlichen Vorgang", "verweist bei %s auf den fachlichen Vorgang",
(typ) => { (typ) => {
const u = darfKorrigiertWerden(eintrag({ event_type: typ }), HEUTE); const u = darfKorrigiertWerden(eintrag({ event_type: typ }), HEUTE);
@@ -39,6 +39,14 @@ describe("darfKorrigiertWerden", () => {
} }
); );
it("lässt Abwesenheit und Rückkehr zu", () => {
// Eine versehentlich erfasste Abwesenheit war sonst nur durch eine zweite
// Buchung loszuwerden — und dann stünden zwei Einträge in der Akte, von
// denen der erste nie stattgefunden hat.
expect(darfKorrigiertWerden(eintrag({ event_type: "Karenz" }), HEUTE).erlaubt).toBe(true);
expect(darfKorrigiertWerden(eintrag({ event_type: "Rückkehr" }), HEUTE).erlaubt).toBe(true);
});
it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => { it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => {
// Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante // Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante
// Änderung nur über Person und Datum finden, und das ist nicht eindeutig. // Änderung nur über Person und Datum finden, und das ist nicht eindeutig.
@@ -113,3 +121,38 @@ describe("loeschVorschau", () => {
expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" }); expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" });
}); });
}); });
// 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.
describe("Reihenfolge von Abwesenheit und Rückkehr", () => {
const karenz = eintrag({ id: "k", event_type: "Karenz", event_date: "2026-07-15", created_at: "2026-07-15T09:00:00.000Z" });
const rueckkehr = eintrag({ id: "r", event_type: "Rückkehr", event_date: "2026-08-10", created_at: "2026-08-10T09:00:00.000Z" });
it("verweigert die Abwesenheit, solange die Rückkehr steht", () => {
const u = darfKorrigiertWerden(karenz, HEUTE, [karenz, rueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/Rückkehr.*zuerst gelöscht/);
});
it("lässt die Rückkehr jederzeit zurücknehmen — sie ist das obere Ende", () => {
expect(darfKorrigiertWerden(rueckkehr, HEUTE, [karenz, rueckkehr]).erlaubt).toBe(true);
});
it("gibt die Abwesenheit frei, sobald die Rückkehr weg ist", () => {
expect(darfKorrigiertWerden(karenz, HEUTE, [karenz]).erlaubt).toBe(true);
});
it("stört sich nicht an einer früheren Rückkehr aus einer anderen Abwesenheit", () => {
const frueher = eintrag({ id: "r0", event_type: "Rückkehr", event_date: "2026-01-05", created_at: "2026-01-05T09:00:00.000Z" });
expect(darfKorrigiertWerden(karenz, HEUTE, [frueher, karenz]).erlaubt).toBe(true);
});
it("lässt eine noch nicht wirksame Abwesenheit in Ruhe", () => {
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1" });
const u = darfKorrigiertWerden(geplant, HEUTE, [geplant]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/);
});
});