diff --git a/db/migrations/20260930120000_befoerderung_benennt_planstelle_um.sql b/db/migrations/20260930120000_befoerderung_benennt_planstelle_um.sql new file mode 100644 index 0000000..e0a3865 --- /dev/null +++ b/db/migrations/20260930120000_befoerderung_benennt_planstelle_um.sql @@ -0,0 +1,252 @@ +-- Eine Beförderung benennt die Planstelle um +-- +-- Bisher schrieb promote_employee den neuen Titel nach employees.job_title — +-- und **nur** dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die +-- Exportdateien zeigen alle die Tätigkeit der Planstelle. Schlimmer noch, der +-- Wert war nicht einmal haltbar: hire_employee, transfer_employee und +-- change_employee_data setzen dieselbe Spalte aus der Planstelle, also +-- überschrieb die nächste Adressänderung den Titel wieder. +-- +-- Gemeldet von Lara („in der zusammenfassung der person ist jedoch noch +-- Marketingtätigkeit angeführt") und im Testprotokoll als H.10. Max hat am +-- 30.09. entschieden: die Beförderung benennt die Planstelle um. +-- +-- ═══ Welche Planstelle ════════════════════════════════════════════ +-- +-- Die, auf der die Person **danach** sitzt: die Zielplanstelle, wenn eine +-- gewählt wurde, sonst die bisherige. Ein Titel, der zu keiner Stelle gehört, +-- war ja gerade das Problem. +-- +-- Im Formular ist das ohne Überraschung, weil es beim Wählen einer +-- Zielplanstelle deren Tätigkeit als Vorschlag einträgt: wer nichts ändert, +-- benennt auch nichts um. +-- +-- ═══ Der Katalog ═════════════════════════════════════════════════ +-- +-- Umbenannt wird nicht der Katalogeintrag, sondern die Planstelle zeigt auf +-- einen anderen. Den Eintrag selbst umzubenennen träfe **jede** Planstelle mit +-- derselben Tätigkeit — bei Manner hängen an einem Eintrag bis zu fünf. +-- +-- Die Regel „gleiche Tätigkeit, ein Katalogeintrag" stand bisher nur in +-- create_position. Sie wandert in job_fuer_titel() und wird von dort aus +-- benutzt; zwei Fassungen derselben Regel laufen in diesem Projekt +-- erfahrungsgemäss auseinander. + +create or replace function job_fuer_titel(p_titel text) +returns uuid language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_titel text := nullif(btrim(p_titel), ''); + v_id uuid; +begin + if v_titel is null then + raise exception 'Es muss eine Tätigkeit angegeben werden.'; + end if; + + -- Ohne Rücksicht auf Gross- und Kleinschreibung: sonst stehen + -- „Schlosser:in" und „schlosser:in" nebeneinander und jede Auswertung nach + -- Tätigkeit ist wertlos. + select id into v_id from jobs where lower(title) = lower(v_titel); + if v_id is not null then + return v_id; + end if; + + -- Die erste freie Nummer statt count(*) + 1. Gezählt wurde bisher, und das + -- vergibt eine bereits belegte Nummer, sobald ein Eintrag gelöscht wurde + -- oder — wie bei der Übernahme der Manner-Daten — Codes aus einer fremden + -- Quelle danebenstehen. + insert into jobs (code, title) + select 'J' || lpad(n::text, 4, '0'), v_titel + from generate_series(1, 100000) n + where not exists (select 1 from jobs j where j.code = 'J' || lpad(n::text, 4, '0')) + order by n + limit 1 + returning id into v_id; + + if v_id is null then + raise exception 'Es ist keine freie Jobnummer mehr frei.'; + end if; + return v_id; +end; +$$; + +comment on function job_fuer_titel(text) is + 'Der Katalogeintrag zu einer Tätigkeit — vorhandener oder neuer. Eine Tätigkeit, ein Eintrag, ohne Rücksicht auf Gross- und Kleinschreibung.'; + +grant execute on function job_fuer_titel(text) to alpenwerk_app; + + +-- ═══ 1. create_position benutzt die Auskunft statt einer zweiten Fassung ══ +do $migration$ +declare + v_alt constant text := $anker$ select id into v_job_id from jobs where lower(title) = lower(v_job_title); + if v_job_id is null then + insert into jobs (code, title) + values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title) + returning id into v_job_id; + end if;$anker$; + v_neu constant text := $anker$ v_job_id := job_fuer_titel(v_job_title);$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f'; + if v_def is null then raise exception 'create_position ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in create_position kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 2. promote_employee benennt die Planstelle um ════════════════ +do $migration$ +declare + v_alt constant text := $anker$ update employees set + job_title = payload->>'new_title', + paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_employee_id;$anker$; + v_neu constant text := $anker$ update employees set + job_title = payload->>'new_title', + paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade) + where id = v_employee_id; + + -- Die Bezeichnung gehört zur Planstelle, nicht zur Person: dort lesen sie + -- Akte, Organigramm und die Exporte. Betroffen ist die Stelle, auf der die + -- Person danach sitzt — die Zielstelle, sonst die bisherige. + declare + v_umbenennen uuid := coalesce(v_position, ( + select pa.position_id from position_assignments pa + where pa.employee_id = v_employee_id and pa.valid_to is null)); + begin + if v_umbenennen is not null then + update om_positions set job_id = job_fuer_titel(payload->>'new_title') + where id = v_umbenennen; + end if; + end;$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ 3. Der Nachtlauf, Zweig `promotion` ══════════════════════════ +-- +-- Eine auf später datierte Beförderung muss dasselbe tun, sonst benennt sie +-- am Stichtag nichts um und der Titel wäre wieder nur an der Person — genau +-- der Zustand, den diese Migration beendet. +do $migration$ +declare + v_alt constant text := $anker$ 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;$anker$; + v_neu constant text := $anker$ 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; + + if nullif(v_rec.payload->>'new_title', '') is not null then + declare + v_umbenennen uuid := coalesce( + nullif(v_rec.payload->>'target_position_id', '')::uuid, ( + select pa.position_id from position_assignments pa + where pa.employee_id = v_rec.employee_id and pa.valid_to is null)); + begin + if v_umbenennen is not null then + update om_positions set job_id = job_fuer_titel(v_rec.payload->>'new_title') + where id = v_umbenennen; + end if; + end; + end if;$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f'; + if v_def is null then raise exception 'apply_due_pending_changes ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + + +-- ═══ Selbstprüfung ════════════════════════════════════════════════ +do $$ +declare + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f'; + if v_def not like '%job_fuer_titel(%' then + raise exception 'create_position benutzt die gemeinsame Auskunft nicht.'; + end if; + if v_def like '%count(*) + 1 from jobs%' then + raise exception 'create_position traegt noch die zweite Fassung der Katalogregel.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'create_position prueft die Rechte nicht.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f'; + if v_def not like '%update om_positions set job_id = job_fuer_titel%' then + raise exception 'promote_employee benennt die Planstelle nicht um.'; + end if; + -- Was an dieser Funktion schon einmal verlorenging. + if v_def not like '%target_position_id%' then + raise exception 'promote_employee hat die Zielplanstelle verloren.'; + end if; + if v_def not like '%valid_from >= v_effective_date%' then + raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.'; + end if; + if v_def not like '%planstelle_vorgemerkt(%' then + raise exception 'promote_employee prueft die vorgemerkten Planstellen nicht mehr.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'promote_employee prueft die Rechte nicht.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f'; + if v_def not like '%job_fuer_titel(v_rec.payload%' then + raise exception 'Der Nachtlauf benennt die Planstelle nicht um.'; + end if; + v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', ''))) + / length('insert into position_assignments'); + if v_anzahl <> 2 then + raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl; + end if; + if v_def not like '%v_rec.payload->''role''%' then + raise exception 'Der Nachtlauf hat die Gruppe role verloren.'; + end if; + + raise notice 'Befoerderung benennt die Planstelle um — sofort und im Nachtlauf.'; +end +$$; diff --git a/db/tests/rauchtest.sql b/db/tests/rauchtest.sql index 12056ad..1b6100e 100644 --- a/db/tests/rauchtest.sql +++ b/db/tests/rauchtest.sql @@ -55,6 +55,7 @@ declare v_offen int; v_meldung text; v_ging boolean; + v_titel text; begin select id into v_wurzel from org_units where parent_id is null; select id into v_ort from locations order by name limit 1; @@ -112,7 +113,34 @@ begin if (select paygrade::text from employees where id = v_a) <> 'HG15' then raise exception 'Die Beförderung hat den Hay-Grade nicht gesetzt.'; end if; - raise notice '4/8 Beförderung am selben Tag, Hay-Grade gesetzt'; + + -- Die Bezeichnung gehört zur Planstelle: dort lesen sie Akte, Organigramm + -- und die Exporte. Vorher stand der neue Titel allein an der Person und war + -- nirgends zu sehen. + select j.title into v_titel + from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3; + if v_titel <> 'Rauchtest Leitung' then + raise exception 'Die Zielplanstelle heisst nach der Beförderung „%", erwartet „Rauchtest Leitung".', v_titel; + end if; + raise notice '4/8 Beförderung am selben Tag: Hay-Grade und Planstellenbezeichnung'; + + -- Dieselbe Planstelle, nur ein neuer Name — der Fall aus dem Testtag. + perform promote_employee(jsonb_build_object( + 'employee_id', v_a, 'effective_date', current_date::text, + 'new_title', 'Rauchtest Leitung Senior', 'new_paygrade', 'HG16')); + + select j.title into v_titel + from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3; + if v_titel <> 'Rauchtest Leitung Senior' then + raise exception 'Ohne Stellenwechsel heisst die Planstelle „%", erwartet „Rauchtest Leitung Senior".', v_titel; + end if; + + -- Der Katalogeintrag der alten Bezeichnung bleibt stehen: an ihm können + -- andere Planstellen hängen. Umbenannt wird die Stelle, nicht der Katalog. + if not exists (select 1 from jobs where title = 'Rauchtest Leitung') then + raise exception 'Der alte Katalogeintrag wurde umbenannt statt die Planstelle umgehängt.'; + end if; + raise notice '4b/8 Beförderung ohne Stellenwechsel benennt dieselbe Planstelle um'; -- ── Vorgemerkte Planstelle (20260929200000) ───────────────────── -- Eine Versetzung in die Zukunft belegt die Zielstelle, obwohl dort noch