Files
alpenwerk-hr/db/migrations/20260929200000_vorgemerkte_planstelle.sql
Andrei Laas a0b9f6dc80
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Anker aus der laufenden Definition, und ein Test, der sich selbst durchwinkt
Zwei Fehler aus dem ersten Lauf des Rauchtests, beide von mir.

Der Anker fuer transfer_employee stammte aus 20260727120200 und fand nichts:
die Funktion ist seither dynamisch gepatcht worden, die Belegungsabfrage ist
zweizeilig und beruecksichtigt den Stichtag. Dieselbe Falle wie immer, nur
diesmal nicht im Rumpf einer Funktion, sondern im Anker auf sie -- fuer
hire_employee und rehire_employee stimmten die Anker, weil sie aus der zuletzt
erzeugten Fassung kamen. Die Migration war nie angewendet (sie bricht als
Ganzes ab), deshalb die Datei korrigiert statt eine neue geschrieben.

Und der Rauchtest hat sich an einer Stelle selbst durchgewunken: das `raise`
fuer den Fehlschlag stand innerhalb des Blocks mit exception-Zweig, wurde also
vom eigenen Handler gefangen -- und weil seine Meldung das Wort "vorgemerkte"
enthielt, bestand die Pruefung auf die erwartete Fehlermeldung. Der Schritt
galt als bestanden, obwohl die Migration gar nicht angewendet war. Jetzt merkt
sich der Block nur, ob es ging, und ausgewertet wird danach.
2026-09-29 19:24:58 +02:00

251 lines
11 KiB
PL/PgSQL

-- Eine Planstelle, auf die jemand vorgemerkt ist, ist nicht frei
--
-- Gemeldet im Test vom 29.09. (F.24): eine Person wurde auf eine Planstelle
-- eingestellt, auf die bereits eine Versetzung zum 20.10. vorgemerkt war. Die
-- Planstellenliste führte die Stelle als unbesetzt, der Assistent bot sie an,
-- und das Organigramm zum 20.10. zeigte sie zweifach besetzt.
--
-- ═══ Warum das schlimmer ist, als es aussieht ═════════════════════
--
-- Eine vorgemerkte Versetzung steht in pending_org_changes und noch nicht in
-- position_assignments. „Besetzt?" wird aber allein an position_assignments
-- gefragt — die Stelle ist zum Zeitpunkt der Buchung tatsächlich frei.
--
-- Auffallen würde es erst in der Nacht zum 20.10.: dann legt der Nachtlauf die
-- Besetzung der vorgemerkten Person an und läuft in
-- `position_assignments_one_holder`, den Teilindex, der genau eine laufende
-- Besetzung je Planstelle zulässt. Der Nachtlauf arbeitet in **einer**
-- Transaktion über alle fälligen Vorgänge; eine einzige solche Buchung bringt
-- ihn also vollständig zum Stehen — auch für alle anderen. Der Fehler entsteht
-- heute und schlägt Wochen später bei jemand anderem zu.
--
-- ═══ Was hier steht ═══════════════════════════════════════════════
--
-- Eine Auskunft, die pending_org_changes mitliest, und ihr Aufruf in den vier
-- Funktionen, die jemanden auf eine Planstelle setzen: hire_employee,
-- rehire_employee, transfer_employee, promote_employee.
--
-- Abgewiesen wird mit Datum — „ab dem 20.10.2026 vorgemerkt" sagt der Person
-- vor dem Bildschirm, was zu tun ist. Die eigene Vormerkung zählt nicht als
-- Hindernis: wer dieselbe Versetzung noch einmal erfasst, korrigiert sie.
create or replace function planstelle_vorgemerkt(p_position_id uuid, p_ab date, p_ausser uuid default null)
returns date language sql stable
set search_path = public, pg_temp
as $$
select min(o.effective_date)
from pending_org_changes o
where o.status = 'pending'
-- Nur die beiden Vorgänge, die eine Planstelle belegen. Eine
-- Vertragsänderung oder eine Karenz trägt keine Zielplanstelle.
and o.change_type in ('transfer', 'promotion')
and o.effective_date >= p_ab
and (p_ausser is null or o.employee_id <> p_ausser)
and nullif(o.payload->>'target_position_id', '')::uuid = p_position_id;
$$;
comment on function planstelle_vorgemerkt(uuid, date, uuid) is
'Ab wann eine Planstelle durch eine vorgemerkte Versetzung oder Beförderung belegt ist, oder null. Die Vormerkung der übergebenen Person zählt nicht mit.';
grant execute on function planstelle_vorgemerkt(uuid, date, uuid) to alpenwerk_app;
-- ═══ hire_employee ════════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_entry, null);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
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 = 'hire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'hire_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 hire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ rehire_employee ══════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
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 = 'rehire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'rehire_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 rehire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ transfer_employee ════════════════════════════════════════════
--
-- Der Anker steht auf der zweiten Zeile der Belegungsabfrage. Die erste
-- Fassung dieser Migration zielte auf
--
-- where pa.position_id = v_target_position and pa.valid_to is null;
--
-- also auf den Wortlaut aus 20260727120200 — und fand nichts. Die Funktion ist
-- seither dynamisch gepatcht worden: die Abfrage ist zweizeilig und
-- berücksichtigt den Stichtag. Dieselbe Falle wie immer, nur diesmal nicht im
-- Rumpf einer Funktion, sondern im Anker auf sie.
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_target_position, v_effective, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
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 = 'transfer_employee' and p.prokind = 'f';
if v_def is null then raise exception 'transfer_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 transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ promote_employee ═════════════════════════════════════════════
--
-- Ohne Zielplanstelle ist v_position null; die Auskunft liefert dann nichts,
-- und der Zweig läuft durch. Eine Beförderung auf derselben Stelle braucht
-- deshalb keine Sonderbehandlung.
do $migration$
declare
v_alt constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;$anker$;
v_neu constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position, v_effective_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
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$;
-- ═══ Selbstprüfung ════════════════════════════════════════════════
do $$
declare
v_name text;
v_def text;
begin
foreach v_name in array array['hire_employee', 'rehire_employee', 'transfer_employee', 'promote_employee'] loop
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 = v_name and p.prokind = 'f';
if v_def not like '%planstelle_vorgemerkt(%' then
raise exception '% fragt die vorgemerkten Planstellen nicht ab.', v_name;
end if;
if v_def not like '%require_hr_admin()%' then
raise exception '% prueft die Rechte nicht.', v_name;
end if;
if v_def not like '%search_path%' then
raise exception '% hat keinen festen search_path.', v_name;
end if;
end loop;
-- Was an diesen Funktionen schon einmal verlorenging. Die Statusumwandlung
-- im Wiedereintritt ist zweimal verschwunden; sie wird hier mitgeprüft,
-- weil diese Migration die Funktion erneut erzeugt.
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 = 'rehire_employee' and p.prokind = 'f';
if v_def not like '%end)::employment_status%' then
raise exception 'rehire_employee wandelt den Status nicht um.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
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 '%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 '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
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 = 'transfer_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective%' then
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
raise notice 'Vorgemerkte Planstellen: alle vier Buchungswege pruefen sie.';
end
$$;