Files
alpenwerk-hr/db/migrations/20260924180000_datierte_aenderung_vollstaendig.sql
Andrei Laas e6b7defae2
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Eine datierte Stammdatenaenderung verlor fuenf 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 traegt ihn apply_due_pending_changes nach. Dessen Zweig
contract_change kannte fuenf Spalten nicht, die der sofortige Weg
schreibt: die drei Felder des Notfallkontakts und beide Titelfelder.

Wer also den Notfallkontakt oder einen akademischen Titel zu einem
kuenftigen Datum erfasste, sah den Vorgang in der Personalakte, und am
Stichtag geschah nichts. Der Eintrag wurde auf "applied" gesetzt, die
Zeile blieb unveraendert, eine Meldung gab es nicht.

Gefunden durch einen Abgleich beider Wege: 44 Spalten schreibt
change_employee_data sofort, 39 der Nachtlauf. Jetzt 44 zu 44. Dieselbe
Luecke wie bei der Gruppe `role` (20260915120000) und bei der Besetzung im
Zweig `transfer` (20260924100000) -- und derselbe Weg, sie zu finden.

Die Selbstpruefung nennt die fuenf Felder ausdruecklich und prueft
zusaetzlich weiter, was hier schon einmal verlorenging.
2026-09-23 20:34:40 +02:00

245 lines
14 KiB
PL/PgSQL

-- 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
$$;