From 92685f0ac00591dbaee2021d9f4ce7154b669930 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 10 Aug 2026 16:10:18 +0200 Subject: [PATCH] Only staff a position while it exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hire_employee and transfer_employee checked whether the position was free, never whether it was there. Someone could be hired today onto a position that starts in October, or onto one that lapsed in spring: the assignment sat in the database while the position was absent from the org chart, and the person hung off a structure that did not exist on their entry date. That stopped being theoretical when the positions view began showing future positions — they now appear in the same picker the hire wizard uses. This is the rule that makes showing them safe. The date of the assignment must fall in [valid_from, valid_to). valid_to is exclusive throughout the model, as in lib/positions.ts. Second correction in the same place: occupancy only looked at assignments with an open end, so one ending later was invisible and the position could be double-booked — the same gap the vacancy list had. And a defect the verification exposed rather than the report: the work_days default in hire_employee never applied. `array(select …)` over a missing key yields an empty array, not null, so coalesce kept `{}` and the CHECK constraint refused the row. Invisible through the wizard, which always sends them and will not proceed without — but a default that defaults to nothing is worse than none, because it reads as though the case was considered. Verified against the live database, all rolled back: a hire onto a future position is refused naming the date it begins, a transfer likewise, a hire onto a currently valid one succeeds — and now also succeeds without work_days, arriving with Mo–Fr. The migrations match on a pattern rather than literal text: the function bodies carry CRLF, and a literal search would have found nothing while the migration reported success. Both refuse to proceed if the pattern matches nothing. Co-Authored-By: Claude Opus 5 --- ...100000_position_validity_on_assignment.sql | 119 ++++++++++++++++++ ...260810110000_fix_hire_workdays_default.sql | 64 ++++++++++ 2 files changed, 183 insertions(+) create mode 100644 supabase/migrations/20260810100000_position_validity_on_assignment.sql create mode 100644 supabase/migrations/20260810110000_fix_hire_workdays_default.sql diff --git a/supabase/migrations/20260810100000_position_validity_on_assignment.sql b/supabase/migrations/20260810100000_position_validity_on_assignment.sql new file mode 100644 index 0000000..e20de73 --- /dev/null +++ b/supabase/migrations/20260810100000_position_validity_on_assignment.sql @@ -0,0 +1,119 @@ +-- Auf eine Planstelle darf nur besetzt werden, solange sie gilt. +-- +-- Bisher prüften hire_employee und transfer_employee nur, ob die Stelle frei +-- ist — nicht, ob es sie zum fraglichen Zeitpunkt überhaupt gibt. Damit liess +-- sich heute jemand auf eine Planstelle einstellen, die erst im Oktober +-- entsteht, oder auf eine, die im Frühjahr ausgelaufen ist. Die Besetzung +-- stand dann in der Datenbank, die Stelle im Organigramm aber nicht, und die +-- Person hing an einer Struktur, die es zu ihrem Eintrittsdatum nicht gab. +-- +-- Seit die Oberfläche künftige Planstellen anzeigt, ist das kein +-- theoretischer Fall mehr: sie stehen in derselben Auswahl. +-- +-- Die Regel: das Datum der Besetzung — Eintritt bzw. Wirksamkeit der +-- Versetzung — muss in [valid_from, valid_to) liegen. `valid_to` ist wie +-- überall im Modell ausschliessend; eine Planstelle mit valid_to = heute gilt +-- heute nicht mehr (siehe lib/positions.ts). +-- +-- Zweite Korrektur im selben Zug: die Belegungsprüfung sah nur Zuordnungen +-- mit offenem Ende. Eine, die erst später endet, blieb unsichtbar — dieselbe +-- Lücke, die die Übersicht der unbesetzten Planstellen hatte. + +create or replace function app_funktion_ersetzen(p_funktion text, p_muster text, p_neu text) +returns void +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_def text; + v_neu 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 = p_funktion + limit 1; + + if v_def is null then + raise exception 'Funktion %() nicht gefunden.', p_funktion; + end if; + + -- Über ein Muster statt über festen Text, weil die Rümpfe je nach Herkunft + -- CRLF oder LF enthalten. Ein wörtlicher Vergleich fände dann nichts und + -- die Migration liefe erfolgreich durch, ohne etwas zu ändern. + v_neu := regexp_replace(v_def, p_muster, p_neu, 'g'); + if v_neu = v_def then + raise exception 'In %() passte das Muster auf nichts — nichts geändert.', p_funktion; + end if; + + execute v_neu; +end; +$$; + +-- ═══ Eintritt ════════════════════════════════════════════════════ +select app_funktion_ersetzen( + 'hire_employee', + 'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_position_id\s+and\s+pa\.valid_to\s+is\s+null;', +$neu$declare + v_ab date; + v_bis date; + begin + select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id; + if v_ab is null then + raise exception 'Die Planstelle existiert nicht.'; + end if; + if v_entry < v_ab then + raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry; + end if; + if v_bis is not null and v_entry >= v_bis then + raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry; + end if; + end; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_position_id + and (pa.valid_to is null or pa.valid_to > v_entry);$neu$ +); + +-- ═══ Versetzung ══════════════════════════════════════════════════ +select app_funktion_ersetzen( + 'transfer_employee', + 'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_target_position\s+and\s+pa\.valid_to\s+is\s+null;', +$neu$declare + v_ab date; + v_bis date; + begin + select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_target_position; + if v_ab is null then + raise exception 'Die Zielplanstelle existiert nicht.'; + end if; + if v_effective < v_ab then + raise exception 'Die Zielplanstelle gilt erst ab %. Eine Versetzung zum % ist darauf nicht möglich.', v_ab, v_effective; + end if; + if v_bis is not null and v_effective >= v_bis then + raise exception 'Die Zielplanstelle gilt nur bis %. Eine Versetzung zum % ist darauf nicht möglich.', v_bis, v_effective; + end if; + end; + + select pa.employee_id into v_besetzt + from position_assignments pa + where pa.position_id = v_target_position + and (pa.valid_to is null or pa.valid_to > v_effective);$neu$ +); + +drop function app_funktion_ersetzen(text, text, text); + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +do $$ +declare r text; +begin + foreach r in array array['hire_employee', 'transfer_employee'] loop + if (select pg_get_functiondef(p.oid) from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = r limit 1) not like '%gilt erst ab%' then + raise exception '%() enthält die Gültigkeitsprüfung nicht.', r; + end if; + end loop; +end; +$$; diff --git a/supabase/migrations/20260810110000_fix_hire_workdays_default.sql b/supabase/migrations/20260810110000_fix_hire_workdays_default.sql new file mode 100644 index 0000000..a0c91f7 --- /dev/null +++ b/supabase/migrations/20260810110000_fix_hire_workdays_default.sql @@ -0,0 +1,64 @@ +-- Der Vorgabewert für die Arbeitstage in hire_employee greift nie. +-- +-- coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::text[], +-- '{Mo,Di,Mi,Do,Fr}') +-- +-- Fehlt der Schlüssel, liefert die Unterabfrage keine Zeilen, und `array(…)` +-- macht daraus ein **leeres** Array — nicht NULL. `coalesce` sieht also +-- keinen fehlenden Wert und lässt `{}` stehen. Die Bedingung +-- chk_work_days_valid verlangt aber mindestens einen Tag, und die +-- Einstellung bricht ab. +-- +-- Sichtbar ist das bisher nicht, weil der Assistent die Arbeitstage immer +-- mitschickt und ohne sie gar nicht weiterlässt. Es ist eine Falle für jeden +-- anderen Aufrufer — und ein Vorgabewert, der nichts vorgibt, ist schlimmer +-- als keiner: er sieht aus, als wäre der Fall bedacht. +-- +-- `nullif(…, '{}')` macht aus dem leeren Array wieder ein fehlendes. + +do $$ +declare + v_def text; + v_neu 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 = 'hire_employee' + limit 1; + + v_neu := regexp_replace( + v_def, + 'coalesce\(\s*array\(\s*select\s+jsonb_array_elements_text\(payload->''work_days''\)\s*\)\s*::text\[\]\s*,', + 'coalesce(nullif(array(select jsonb_array_elements_text(payload->''work_days''))::text[], ''{}''),', + 'g' + ); + + if v_neu = v_def then + raise exception 'Das Muster für die Arbeitstage passte nicht — hire_employee blieb unverändert.'; + end if; + + execute v_neu; +end; +$$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +-- Der Ausdruck in beiden Formen, damit die Regel festgehalten ist und nicht +-- beim nächsten Mal neu entdeckt werden muss. +do $$ +begin + if array(select jsonb_array_elements_text('{}'::jsonb->'work_days')) is null then + raise exception 'array() über einen fehlenden Schlüssel liefert null — die Annahme dieser Migration stimmt nicht mehr.'; + end if; + + if coalesce(nullif(array(select jsonb_array_elements_text('{}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}') + <> '{Mo,Di,Mi,Do,Fr}'::text[] then + raise exception 'Der korrigierte Ausdruck liefert nicht die Vorgabe.'; + end if; + + if coalesce(nullif(array(select jsonb_array_elements_text('{"work_days":["Mo","Di"]}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}') + <> '{Mo,Di}'::text[] then + raise exception 'Der korrigierte Ausdruck überschreibt einen mitgegebenen Wert.'; + end if; +end; +$$;