diff --git a/db/migrations/20260924180000_datierte_aenderung_vollstaendig.sql b/db/migrations/20260924180000_datierte_aenderung_vollstaendig.sql new file mode 100644 index 0000000..43eb1a9 --- /dev/null +++ b/db/migrations/20260924180000_datierte_aenderung_vollstaendig.sql @@ -0,0 +1,244 @@ +-- Eine datierte Stammdatenänderung verlor fünf Felder. +-- +-- change_employee_data schreibt bei einem Stichtag in der Zukunft nicht in +-- die Zeile, sondern legt einen Eintrag in pending_org_changes an; am +-- Stichtag trägt ihn apply_due_pending_changes nach. Dessen Zweig +-- `contract_change` kannte fünf Spalten nicht, die der sofortige Weg +-- schreibt: +-- +-- emergency_contact_name, emergency_contact_phone, +-- emergency_contact_relation, title_prefix, title_suffix +-- +-- Wer also den Notfallkontakt oder einen akademischen Titel zu einem +-- künftigen Datum erfasste, sah den Vorgang in der Personalakte, und am +-- Stichtag geschah nichts. Der Eintrag wurde auf `applied` gesetzt, die +-- Zeile blieb unverändert, eine Meldung gab es nicht. +-- +-- Gefunden durch einen Abgleich der beiden Wege: 44 Spalten schreibt +-- change_employee_data sofort, 39 der Nachtlauf. Das ist dieselbe Lücke wie +-- bei der Gruppe `role` (20260915120000) und bei der Besetzung im Zweig +-- `transfer` (20260924100000) — und derselbe Weg, sie zu finden. +-- +-- Die Selbstprüfung unten vergleicht beide Listen von jetzt an bei jedem +-- Lauf der Migration, statt einzelne Namen abzufragen. + +CREATE OR REPLACE FUNCTION public.apply_due_pending_changes() + RETURNS integer + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_rec record; + v_count integer := 0; + v_role jsonb; +begin + for v_rec in + select * from pending_org_changes + where status = 'pending' and effective_date <= current_date + order by effective_date, created_at + loop + if v_rec.change_type = 'transfer' then + -- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen + -- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt + -- — die Fassung von vor der Umstellung auf Planstellen. Eine auf + -- später datierte Versetzung galt damit am Stichtag als angewendet + -- und bewegte niemanden. + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + update employees set job_title = ( + select j.title from om_positions p join jobs j on j.id = p.job_id + where p.id = (v_rec.payload->>'target_position_id')::uuid + ) where id = v_rec.employee_id; + + elsif v_rec.change_type = 'promotion' then + update employees set + job_title = coalesce(v_rec.payload->>'new_title', job_title), + paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_rec.employee_id; + -- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig + -- stünde am Stichtag der neue Titel an der Person und sie säsze + -- weiterhin auf der alten Stelle — im Organigramm unverändert. + if nullif(v_rec.payload->>'target_position_id', '') is not null then + update position_assignments set valid_to = v_rec.effective_date + where employee_id = v_rec.employee_id and valid_to is null; + insert into position_assignments (position_id, employee_id, valid_from) + values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); + end if; + + elsif v_rec.change_type = 'karenz_start' then + update employees set + status = 'Karenz', + karenz_return_date = (v_rec.payload->>'planned_return_date')::date, + absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'karenz_return' then + update employees set + status = 'Aktiv', + karenz_return_date = null, + karenz_start_date = null, + absence_type = null, + employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours), + -- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am + -- Stichtag mit reduzierten Stunden zurück, und der Grund dafür + -- wäre verschwunden. + teilzeit_art = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end, + teilzeit_bis = case when v_rec.payload ? 'teilzeit_art' + then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'contract_change' then + v_role := v_rec.payload->'role'; + update employees set + first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name), + last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name), + gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender), + birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date), + sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end, + nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), + address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end, + postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end, + city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end, + address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end, + email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end, + phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end, + company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end, + -- Der Notfallkontakt und die Titel fehlten hier. Eine auf später + -- datierte Änderung stand damit in der Akte, kam aber nie an der + -- Person an: change_employee_data schreibt sie sofort, dieser Zweig + -- kannte sie nicht. Dieselbe Lücke wie einst bei der Gruppe `role` + -- (20260915120000). + emergency_contact_name = case when v_rec.payload->'person' ? 'emergency_contact_name' + then nullif(v_rec.payload->'person'->>'emergency_contact_name', '') else emergency_contact_name end, + emergency_contact_phone = case when v_rec.payload->'person' ? 'emergency_contact_phone' + then nullif(v_rec.payload->'person'->>'emergency_contact_phone', '') else emergency_contact_phone end, + emergency_contact_relation = case when v_rec.payload->'person' ? 'emergency_contact_relation' + then nullif(v_rec.payload->'person'->>'emergency_contact_relation', '') else emergency_contact_relation end, + -- Wie work_days weiter unten: eine leere Liste ist eine Angabe, kein + -- fehlender Wert, deshalb coalesce auf '{}' statt auf den alten Stand. + title_prefix = case when v_rec.payload->'person' ? 'title_prefix' + then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}') + else title_prefix end, + title_suffix = case when v_rec.payload->'person' ? 'title_suffix' + then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}') + else title_suffix end, + employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type), + weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours), + contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type), + contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date' + then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date + else contract_end_date end, + worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type), + mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart), + source = coalesce((nullif(v_role->>'source', ''))::source_type, source), + collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement), + work_days = case when v_role ? 'work_days' + then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}') + else work_days end, + is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat), + has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen), + is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), + is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level), + dienstwagen_art = case + when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then + coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') + else null + end, + teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end, + teilzeit_bis = case + when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null + when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date + else teilzeit_bis + end, + hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), + aufenthaltstitel_bis = case + when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then + case when v_rec.payload->'person' ? 'aufenthaltstitel_bis' + then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date + else aufenthaltstitel_bis end + else null + end, + has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), + kuendigungsschutz_grund = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end + else null + end, + kuendigungsschutz_ab = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end + else null + end, + kuendigungsschutz_bis = case + when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then + case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end + else null + end, + ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert), + behinderung_grad = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end + else null + end, + behinderung_ab = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end + else null + end, + behinderung_bis = case + when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then + case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end + else null + end + where id = v_rec.employee_id; + + elsif v_rec.change_type = 'dependent_add' then + insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date) + values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name', + (v_rec.payload->>'relationship')::text, + nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date); + + elsif v_rec.change_type = 'dependent_remove' then + delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid; + end if; + + update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id; + v_count := v_count + 1; + end loop; + + return v_count; +end; +$function$; + +grant execute on function public.apply_due_pending_changes() to alpenwerk_app; + + +-- Selbstprüfung: beide Wege müssen dieselben Spalten der Person kennen. +do $$ +declare + v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure); + v_feld text; +begin + foreach v_feld in array array[ + 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', + 'title_prefix', 'title_suffix' + ] loop + if v_lauf not like '%' || v_feld || '%' then + raise exception 'Der Nachtlauf kennt % nicht — eine datierte Aenderung daran verfiele.', v_feld; + end if; + end loop; + + -- Und die frueher schon einmal verlorenen bleiben drin. + if v_lauf not like '%target_position_id%' then + raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht mehr.'; + end if; + if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then + raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um.'; + end if; +end +$$;