Files
alpenwerk-hr/supabase/migrations/20260813180000_update_history_entry.sql
Maximilian Stubhan 08d2740690 Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything
not yet effective stayed put. That was not a principle, it was a missing
link. A planned change lives as a payload in pending_org_changes, and
nothing tied it to the history row — only a person and a date, and the
data already holds an Eintritt and a Vertragsänderung sharing one. So
employee_history now carries pending_id, set by change_employee_data
when it schedules something.

One planned change can carry two history rows: Stammdaten and Vertrag
are kept apart but scheduled together. Taking one back therefore strips
only that group's fields from the payload, and cancels the operation
only when nothing is left. Correcting one rewrites its group and the
effective date, and touches no employee data — the change has not
happened yet.

An entry stays on its side of the present. Pulling a planned change into
today, or pushing an effective one into the future, would mean adjusting
the employee record and the pending payload in opposite directions;
that is what the real operations are for.

Existing rows were linked where exactly one running operation matched
the person and date and no other row had claimed it. All five of them
matched. Anything ambiguous would have kept the old refusal, which now
says the actual reason.

The edit dialog surfaced a bug in useDialogFocus that predates it: the
effect depended on the identity of onClose, which almost every caller
rebuilds on render, so it re-ran after each keystroke and its cleanup
pulled focus back to whatever opened the dialog. Any dialog with a text
field would have accepted one character. It never showed because until
now no dialog kept its own state next to its own onClose.

Rehearsed against real data: a two-row planned change corrected, one row
taken back with the operation continuing on the rest, the second taken
back with the operation cancelled, and both refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:35:51 +02:00

367 lines
16 KiB
PL/PgSQL

-- Historieneinträge berichtigen — und die Feldtabelle nur noch einmal führen.
--
-- Drei Teile: die gemeinsame Feldtabelle, die bisher im Rumpf der
-- Löschfunktion stand; dieselbe Löschfunktion, nun auf die gemeinsame
-- Tabelle umgestellt; und das Berichtigen als neue Funktion.
-- Die Feldtabelle einmal, für alle, die sie brauchen.
--
-- Beschriftung → Spalte und Typ. Sie stand bisher im Rumpf von
-- delete_history_entry; mit dem Bearbeiten kam eine zweite Stelle dazu, die
-- sie genauso braucht. Zwei Kopien einer solchen Liste laufen auseinander,
-- sobald ein Feld hinzukommt — und dann lässt sich ein Feld löschen, aber
-- nicht korrigieren, ohne dass es jemandem auffällt.
--
-- Geschlossene Liste: was change_employee_data schreiben kann, steht hier,
-- sonst nichts. Die Spaltennamen gehen in dynamisches SQL und dürfen nur
-- von hier kommen.
create or replace function 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'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
$function$;
comment on function app_feld_karte() is 'Beschriftung eines Feldes → [Spalte, Typ]. Quelle für das Zurücksetzen und Korrigieren von Historieneinträgen.';
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_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := app_feld_karte();
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') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort 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;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
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; -- unbekannte Beschriftung: nichts anfassen
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;
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
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
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
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$;
-- Einen Historieneintrag berichtigen.
--
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
-- von denen der erste nie stattgefunden hat.
--
-- Geändert werden darf das **Nachher** und das **Datum**. Das Vorher bleibt:
-- es beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
-- nicht anders beschliessen.
--
-- ═══ Wie der heutige Stand danach zustande kommt ═══
--
-- Nicht durch Zurückrechnen, sondern durch Nachsehen: für jedes betroffene
-- Feld gewinnt der **jüngste** Historieneintrag, der es trägt. Das ist
-- dieselbe Regel wie beim Löschen — „die letztgültige Änderung ist die
-- schlagende" — nur von der anderen Seite gelesen, und sie trägt hier
-- zusätzlich den Fall, dass sich durch ein neues Datum die Reihenfolge
-- verschiebt.
--
-- ═══ Was nicht geht ═══
--
-- Dieselben Grenzen wie beim Löschen: kein Eintritt, nur Stammdaten- und
-- Vertragsänderungen, nichts Zukünftiges, nichts ohne Feldwerte. Ein Datum
-- in der Zukunft würde aus dem Eintrag eine geplante Änderung machen, und
-- die lebt in pending_org_changes — dorthin führt kein verlässlicher Weg
-- zurück (kein Schlüssel zwischen beiden Tabellen).
create or replace function 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_gueltig text;
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') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort 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_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
if v_datum > current_date then
raise exception 'Ein Datum in der Zukunft macht daraus eine geplante Änderung. Dafür ist dieser Weg nicht gedacht.';
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;
-- Für jedes betroffene Feld den jüngsten Eintrag suchen, der es trägt, und
-- dessen Nachher setzen. Das schliesst den eben berichtigten Eintrag ein
-- und berücksichtigt ein verschobenes Datum von selbst.
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
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;
-- Alles in einem UPDATE: chk_weekly_hours koppelt Beschäftigungsausmaß und
-- Wochenstunden, und zwischen zwei getrennten Anweisungen stünde 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 '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$;
comment on function update_history_entry(jsonb) is
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Der heutige Stand wird je Feld aus dem jüngsten Eintrag abgeleitet, der es trägt. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
-- Selbstprüfung.
do $$
declare
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 jsonb_typeof(app_feld_karte()) <> 'object' then
raise exception 'app_feld_karte liefert kein Objekt';
end if;
if not (app_feld_karte() ? 'Adresse' and app_feld_karte() ? 'Wochenstunden') then
raise exception 'Die Feldtabelle ist unvollständig';
end if;
if v_del not like '%app_feld_karte()%' then
raise exception 'delete_history_entry nutzt die gemeinsame Feldtabelle nicht';
end if;
if v_del like '%jsonb_build_array(''first_name''%' then
raise exception 'delete_history_entry trägt noch eine eigene Kopie der Feldtabelle';
end if;
if not (select prosecdef from pg_proc where oid = 'public.update_history_entry(jsonb)'::regprocedure) then
raise exception 'update_history_entry muss SECURITY DEFINER sein';
end if;
if v_upd not like '%require_hr_admin%' then
raise exception 'update_history_entry prüft die Berechtigung nicht';
end if;
-- Die Policies bleiben, wie sie sind.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
) then
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
end if;
end
$$;