diff --git a/db/migrations/20260929220000_austritt_am_selben_tag.sql b/db/migrations/20260929220000_austritt_am_selben_tag.sql new file mode 100644 index 0000000..ae9064e --- /dev/null +++ b/db/migrations/20260929220000_austritt_am_selben_tag.sql @@ -0,0 +1,76 @@ +-- Austritt am Tag, an dem die Besetzung begann +-- +-- Die fünfte Stelle mit demselben Paar aus Schliessen und Einfügen, gefunden +-- beim Schreiben des Rauchtests: terminate_employee schliesst die Besetzung auf +-- das Austrittsdatum. Wer heute eingestellt und heute wieder ausgetragen wird — +-- ohne den Grund „No Show" —, läuft damit in chk_assignment_range. +-- +-- Die No-Show-Hälfte derselben Funktion macht es längst richtig und erklärt +-- auch, warum: eine Besetzung von null Tagen hat nicht stattgefunden, also wird +-- sie entfernt statt geschlossen. Nur galt das bisher für genau einen +-- Austrittsgrund. Ein Eintritt am selben Tag zurückgenommen, aber mit +-- „Einvernehmliche Auflösung" statt „No Show", scheiterte an der rohen +-- Datenbankmeldung. +-- +-- Damit sind es fünf: hire/rehire brauchen es nicht (sie legen an, sie +-- schliessen nicht), transfer_employee, promote_employee und die beiden Zweige +-- des Nachtlaufs stehen seit 20260929180000, terminate_employee kommt hier +-- dazu. + +do $migration$ +declare + v_alt constant text := $anker$update position_assignments set valid_to = v_exit$anker$; + v_neu constant text := $anker$delete from position_assignments + where employee_id = v_employee_id and valid_to is null and valid_from >= v_exit; + update position_assignments set valid_to = v_exit$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 = 'terminate_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'terminate_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 terminate_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + + +-- Selbstprüfung: das Neue und alles, was an dieser Funktion schon einmal +-- verlorengegangen ist. Die Anmerkung ist beim Umbau auf No-Show einmal +-- stillschweigend verschwunden und fiel erst durch eine Frage des Kunden auf. +do $$ +declare + v_def text; +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 = 'terminate_employee' and p.prokind = 'f'; + + if v_def not like '%valid_from >= v_exit%' then + raise exception 'terminate_employee raeumt die Besetzung vom selben Tag nicht ab.'; + end if; + if v_def not like '%No Show%' then + raise exception 'terminate_employee kennt den Nichtantritt nicht mehr.'; + end if; + if v_def not like '%v_note%' then + raise exception 'terminate_employee schreibt die Anmerkung nicht.'; + end if; + if v_def not like '%austrittsart%' then + raise exception 'terminate_employee schreibt die Austrittsart nicht.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'terminate_employee prueft die Rechte nicht.'; + end if; + if v_def not like '%search_path%' then + raise exception 'terminate_employee hat keinen festen search_path.'; + end if; + + raise notice 'terminate_employee: Austritt am selben Tag abgeraeumt.'; +end +$$; diff --git a/db/tests/rauchtest.sql b/db/tests/rauchtest.sql new file mode 100644 index 0000000..ddf39bd --- /dev/null +++ b/db/tests/rauchtest.sql @@ -0,0 +1,185 @@ +-- Rauchtest: der Lebenszyklus, einmal wirklich ausgeführt +-- +-- docker compose run --rm psql -f /repo/db/tests/rauchtest.sql +-- +-- ═══ Warum es das gibt ════════════════════════════════════════════ +-- +-- rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt: ein fehlender +-- Umwandlungsschritt beim Status. Aufgefallen ist es erst beim Handtest am +-- 29.09. Keine Prüfung der Anwendung hat es bemerkt, und keine konnte es: +-- die Selbstprüfungen der Migrationen lesen den **Text** der Funktionen, die +-- Unit-Tests laufen ohne Datenbank, und die Integrationstests haben keinen +-- Bestand, gegen den sie liefen. +-- +-- Diese Datei schliesst genau diese Lücke: sie **ruft** die Funktionen auf, +-- gegen die echte Datenbank, in der Reihenfolge des Lebenszyklus. +-- +-- ═══ Zwei Eigenschaften, ohne die es gefährlich wäre ══════════════ +-- +-- 1. **Es wird zurückgerollt.** Alles läuft in einer Transaktion, die am Ende +-- verworfen wird. Nach dem Lauf steht kein Testmensch, keine Testplanstelle +-- und keine Testeinheit in der Datenbank. Der Anlass steht daneben: beim +-- Test vom 29.09. sind reale Personen auf Testplanstellen umgezogen und +-- dort geblieben. +-- +-- 2. **Es läuft als Anwendungsrolle.** psql verbindet sich als Superuser, und +-- der geht an jeder Zeilenschutz-Regel und an require_hr_admin() vorbei. +-- Ein Lauf als postgres würde beweisen, dass die Funktionen für niemanden +-- funktionieren, der sie benutzt. +-- +-- Fehlschläge brechen mit einer Meldung ab, die den Schritt nennt. Kommt der +-- Lauf bis „Rauchtest bestanden" durch, ist der Lebenszyklus gegangen worden. + +\set ON_ERROR_STOP on + +begin; + +select set_config( + 'app.user_id', + (select p.id::text from profiles p where p.role = 'hr' and p.is_active limit 1), + true +) as angemeldet_als; + +set local role alpenwerk_app; + +do $rauchtest$ +declare + v_wurzel uuid; + v_einheit uuid; + v_p1 uuid; v_p2 uuid; v_p3 uuid; v_p4 uuid; + v_a uuid; v_b uuid; + v_ort uuid; + v_nr int; + v_status text; + v_stelle uuid; + v_offen int; + v_meldung 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; + select coalesce(max(personnel_number), 0) + 1 into v_nr from employees; + if v_wurzel is null or v_ort is null then + raise exception 'Kein Bestand: es fehlt die oberste Einheit oder ein Standort.'; + end if; + + -- ── Spielwiese ────────────────────────────────────────────────── + v_einheit := create_org_unit(jsonb_build_object( + 'parent_id', v_wurzel, 'org_number', 'RAUCHTEST', 'name', 'Rauchtest', + 'unit_type', 'Team', 'valid_from', (current_date - 30)::text)); + + v_p1 := create_position(jsonb_build_object('org_unit_id', v_einheit, + 'job_title', 'Rauchtest Eins', 'is_chief', false, 'valid_from', (current_date - 30)::text)); + v_p2 := create_position(jsonb_build_object('org_unit_id', v_einheit, + 'job_title', 'Rauchtest Zwei', 'is_chief', false, 'valid_from', (current_date - 30)::text)); + v_p3 := create_position(jsonb_build_object('org_unit_id', v_einheit, + 'job_title', 'Rauchtest Drei', 'is_chief', false, 'valid_from', (current_date - 30)::text)); + v_p4 := create_position(jsonb_build_object('org_unit_id', v_einheit, + 'job_title', 'Rauchtest Vier', 'is_chief', false, 'valid_from', (current_date - 30)::text)); + raise notice '1/8 Einheit und vier Planstellen angelegt'; + + -- ── Einstellung ───────────────────────────────────────────────── + v_a := hire_employee(jsonb_build_object( + 'personnel_number', v_nr, 'first_name', 'Rauch', 'last_name', 'Test-A', + 'gender', 'w', 'birth_date', '1990-01-01', 'location_id', v_ort, + 'position_id', v_p1, 'entry_date', current_date::text)); + raise notice '2/8 Einstellung auf Planstelle 1'; + + -- ── Versetzung am selben Tag (20260929180000) ─────────────────── + -- Die Besetzung begann heute; sie auf heute zu schliessen wäre ein + -- Intervall von null Tagen. Vorher: chk_assignment_range. + perform transfer_employee(jsonb_build_object( + 'employee_id', v_a, 'target_position_id', v_p2, 'effective_date', current_date::text)); + + select position_id into v_stelle from position_assignments + where employee_id = v_a and valid_to is null; + if v_stelle is distinct from v_p2 then + raise exception 'Nach der Versetzung sitzt die Person nicht auf Planstelle 2.'; + end if; + raise notice '3/8 Versetzung am selben Tag'; + + -- ── Beförderung mit Planstellenwechsel, ebenfalls heute ───────── + perform promote_employee(jsonb_build_object( + 'employee_id', v_a, 'effective_date', current_date::text, + 'new_title', 'Rauchtest Leitung', 'new_paygrade', 'HG15', + 'target_position_id', v_p3)); + + select position_id into v_stelle from position_assignments + where employee_id = v_a and valid_to is null; + if v_stelle is distinct from v_p3 then + raise exception 'Nach der Beförderung sitzt die Person nicht auf Planstelle 3.'; + end if; + 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'; + + -- ── Vorgemerkte Planstelle (20260929200000) ───────────────────── + -- Eine Versetzung in die Zukunft belegt die Zielstelle, obwohl dort noch + -- keine Besetzung steht. Wer sie vorher besetzt, bringt den Nachtlauf zum + -- Stehen — vorher ging genau das durch. + perform transfer_employee(jsonb_build_object( + 'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 10)::text)); + + begin + perform hire_employee(jsonb_build_object( + 'personnel_number', v_nr + 1, 'first_name', 'Rauch', 'last_name', 'Test-B', + 'gender', 'm', 'birth_date', '1990-01-01', 'location_id', v_ort, + 'position_id', v_p4, 'entry_date', current_date::text)); + raise exception 'Die vorgemerkte Planstelle liess sich besetzen.'; + exception + when others then + v_meldung := SQLERRM; + if v_meldung not like '%vorgemerkt%' then + raise exception 'Erwartet wurde die Meldung zur Vormerkung, kam aber: %', v_meldung; + end if; + end; + raise notice '5/8 Vorgemerkte Planstelle wird abgewiesen'; + + -- Die eigene Vormerkung darf nicht im Weg stehen: dieselbe Versetzung noch + -- einmal zu erfassen ist eine Berichtigung. + perform transfer_employee(jsonb_build_object( + 'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 20)::text)); + raise notice '6/8 Die eigene Vormerkung steht nicht im Weg'; + + -- ── Austritt am selben Tag (20260929220000) ───────────────────── + perform terminate_employee(jsonb_build_object( + 'employee_id', v_a, 'exit_date', current_date::text, + 'exit_reason', 'Einvernehmliche Auflösung', 'austrittsart', 'freiwillig')); + + select count(*) into v_offen from position_assignments + where employee_id = v_a and valid_to is null; + if v_offen <> 0 then + raise exception 'Nach dem Austritt ist die Planstelle nicht frei.'; + end if; + raise notice '7/8 Austritt am selben Tag, Planstelle frei'; + + -- ── Wiedereintritt (20260929160000, K.01) ─────────────────────── + perform rehire_employee(jsonb_build_object( + 'employee_id', v_a, 'rehire_date', current_date::text, 'position_id', v_p1, + 'first_name', 'Rauch', 'last_name', 'Test-A', 'gender', 'w', + 'birth_date', '1990-01-01', 'nationality', 'Österreich', 'location_id', v_ort)); + + select status::text into v_status from employees where id = v_a; + if v_status <> 'Aktiv' then + raise exception 'Nach dem Wiedereintritt steht der Status auf %, erwartet Aktiv.', v_status; + end if; + select position_id into v_stelle from position_assignments + where employee_id = v_a and valid_to is null; + if v_stelle is distinct from v_p1 then + raise exception 'Nach dem Wiedereintritt sitzt die Person nicht auf Planstelle 1.'; + end if; + raise notice '8/8 Wiedereintritt'; + + raise notice ''; + raise notice 'Rauchtest bestanden. Alles Angelegte wird gleich verworfen.'; +end +$rauchtest$; + +rollback; + +-- Beweis, dass nichts geblieben ist. Läuft ausserhalb der verworfenen +-- Transaktion und muss überall 0 liefern. +select + (select count(*) from org_units where org_number = 'RAUCHTEST') as einheiten, + (select count(*) from employees where last_name like 'Test-%' and first_name = 'Rauch') as personen, + (select count(*) from jobs where title like 'Rauchtest %') as taetigkeiten;