diff --git a/supabase/migrations/20260813160000_revert_in_one_statement.sql b/supabase/migrations/20260813160000_revert_in_one_statement.sql new file mode 100644 index 0000000..a66d3f8 --- /dev/null +++ b/supabase/migrations/20260813160000_revert_in_one_statement.sql @@ -0,0 +1,185 @@ +-- Zurücksetzen in einem Zug, nicht Feld für Feld. +-- +-- Die erste Fassung schrieb je Feld ein eigenes UPDATE. Das ist bei +-- unabhängigen Feldern harmlos und bei gekoppelten falsch: chk_weekly_hours +-- verlangt, dass Beschäftigungsausmaß und Wochenstunden zueinander passen +-- (Vollzeit genau 38,5; Teilzeit dazwischen). Wird zuerst das Ausmaß auf +-- „Vollzeit" zurückgesetzt, während noch 37 Stunden dastehen, verbietet die +-- Bedingung genau diesen Zwischenstand — und das Löschen scheiterte mit +-- „new row for relation employees violates check constraint". +-- +-- Es traf jede Rücknahme einer Vertragsänderung, die beide Felder betraf, +-- also praktisch jede: die Oberfläche ändert Ausmaß und Stunden zusammen. +-- +-- Jetzt werden die Zuweisungen gesammelt und in einer einzigen Anweisung +-- geschrieben. Damit entsteht der verbotene Zwischenstand gar nicht — der +-- Zustand von davor war ja gültig, sonst stünde er nicht in der Historie. +-- +-- Bleibt danach doch eine Verletzung, ist sie echt: dann hat eine spätere +-- Änderung eines der gekoppelten Felder einzeln angefasst, und der alte Wert +-- passt nicht mehr zum heutigen Stand. Dieser Fall wird abgefangen und als +-- Satz gemeldet, statt als Datenbankfehler durchzuschlagen. + +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 := 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') + ); +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$; + + +-- Selbstprüfung. +do $$ +declare + v_def text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure); +begin + if v_def like '%update employees set %I%' then + raise exception 'Es wird noch je Feld einzeln geschrieben'; + end if; + if v_def not like '%array_to_string(v_setz%' then + raise exception 'Das gesammelte UPDATE fehlt'; + end if; + if v_def not like '%check_violation%' then + raise exception 'Die Verletzung wird nicht abgefangen'; + end if; +end +$$;