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>
367 lines
16 KiB
PL/PgSQL
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
|
|
$$;
|