Put the fields back in one statement, not one at a time
Every deletion of a Vertragsänderung failed with "new row for relation employees violates check constraint chk_weekly_hours". The revert wrote one UPDATE per field, and chk_weekly_hours ties two of them together: Vollzeit means exactly 38.5 hours, Teilzeit means something in between. Setting the employment type back to Vollzeit while 37 hours still stood produced precisely the state the constraint forbids. It hit nearly every contract change, because the form changes those two together. Collecting the assignments and writing them in a single UPDATE removes the intermediate state entirely. The state being restored was valid once — it is in the history because it was — so restoring it whole is safe. A violation can still be real: if a later change touched one of a coupled pair on its own, the old value no longer fits today's state. That case is caught and reported as a sentence instead of surfacing a database error in a toast. My tests did not catch this, and could not have: the revert lives in SQL and the suite has no way to run it. What did catch it was HR clicking the button. The rehearsal script now covers the reported case, an unrelated single-field revert, and the genuine conflict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
185
supabase/migrations/20260813160000_revert_in_one_statement.sql
Normal file
185
supabase/migrations/20260813160000_revert_in_one_statement.sql
Normal file
@@ -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
|
||||
$$;
|
||||
Reference in New Issue
Block a user