Eine Befoerderung benennt die Planstelle um
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled

Bisher schrieb promote_employee den neuen Titel nach employees.job_title -- und
nur dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die Exporte zeigen
alle die Taetigkeit der Planstelle. Haltbar war er auch nicht, denn
hire_employee, transfer_employee und change_employee_data setzen dieselbe
Spalte aus der Planstelle; die naechste Adressaenderung ueberschrieb ihn wieder.

Gemeldet von Lara und im Testprotokoll als H.10. Max hat entschieden: die
Befoerderung benennt die Planstelle um.

Umbenannt wird die Stelle, auf der die Person danach sitzt -- die Zielstelle,
sonst die bisherige. Im Formular ist das ohne Ueberraschung, weil es beim
Waehlen einer Zielplanstelle deren Taetigkeit als Vorschlag eintraegt: wer
nichts aendert, benennt auch nichts um. Und umbenannt wird nicht der
Katalogeintrag, sondern die Planstelle zeigt auf einen anderen -- den Eintrag
selbst umzubenennen traefe jede Planstelle mit derselben Taetigkeit.

Die Regel "gleiche Taetigkeit, ein Katalogeintrag" stand nur in
create_position und wandert nach job_fuer_titel(); zwei Fassungen derselben
Regel laufen hier erfahrungsgemaess auseinander. Die Nummernvergabe nimmt dabei
die erste freie statt count(*) + 1 -- gezaehlt wurde bisher, und das vergibt
eine belegte Nummer, sobald ein Eintrag geloescht wurde oder Codes aus einer
fremden Quelle danebenstehen, wie bei der Uebernahme der Manner-Daten.

Der Nachtlauf tut dasselbe, sonst benennt eine datierte Befoerderung am
Stichtag nichts um. Der Rauchtest prueft beide Wege und dass der alte
Katalogeintrag stehen bleibt.
This commit is contained in:
2026-09-29 20:29:56 +02:00
parent fe858c6c24
commit 38f8c99968
2 changed files with 281 additions and 1 deletions

View File

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

View File

@@ -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