Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
This commit is contained in:
140
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
140
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
|
||||
-- nachziehen.
|
||||
--
|
||||
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
|
||||
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
|
||||
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
|
||||
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
|
||||
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
|
||||
-- Aufruf würde erst zur Laufzeit scheitern.
|
||||
|
||||
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
|
||||
drop function if exists create_position(jsonb);
|
||||
drop function if exists delete_position(jsonb);
|
||||
drop function if exists delete_position(uuid);
|
||||
drop function if exists staff_position_internally(jsonb);
|
||||
drop function if exists apply_reorg(jsonb);
|
||||
drop function if exists undo_reorg(jsonb);
|
||||
drop function if exists undo_reorg(uuid);
|
||||
|
||||
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
|
||||
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
|
||||
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
|
||||
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
|
||||
alter table employee_history drop column if exists reorg_scenario_id;
|
||||
alter table pending_org_changes drop column if exists reorg_scenario_id;
|
||||
drop table if exists reorg_moves;
|
||||
drop table if exists reorg_scenarios;
|
||||
|
||||
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
|
||||
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
|
||||
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
|
||||
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
|
||||
-- und Schliessen sind deshalb Operationen auf om_positions.
|
||||
|
||||
create or replace function next_position_number()
|
||||
returns text language sql stable as $$
|
||||
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
|
||||
from om_positions
|
||||
where position_number ~ '^6[0-9]{7}$';
|
||||
$$;
|
||||
|
||||
comment on function next_position_number() is
|
||||
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
|
||||
|
||||
create or replace function create_position(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
|
||||
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
||||
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
|
||||
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
||||
v_job_id uuid;
|
||||
v_position_id uuid;
|
||||
v_unit_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select name into v_unit_name from org_units where id = v_org_unit_id;
|
||||
if v_unit_name is null then
|
||||
raise exception 'Die Organisationseinheit existiert nicht.';
|
||||
end if;
|
||||
if v_job_title is null then
|
||||
raise exception 'Es muss eine Tätigkeit angegeben werden.';
|
||||
end if;
|
||||
|
||||
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
|
||||
-- die in der Oberfläche nichts erklärt.
|
||||
if v_is_chief and exists (
|
||||
select 1 from om_positions
|
||||
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
|
||||
) then
|
||||
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
||||
end if;
|
||||
|
||||
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
|
||||
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
|
||||
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;
|
||||
|
||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
||||
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
|
||||
returning id into v_position_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
|
||||
v_job_title || ' (' || v_unit_name || ')',
|
||||
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
|
||||
|
||||
return v_position_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function delete_position(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_label text;
|
||||
v_hat_historie boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select j.title || ' (' || u.name || ')' into v_label
|
||||
from om_positions p
|
||||
join jobs j on j.id = p.job_id
|
||||
join org_units u on u.id = p.org_unit_id
|
||||
where p.id = v_position_id;
|
||||
if v_label is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
|
||||
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
|
||||
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
|
||||
end if;
|
||||
|
||||
select exists (select 1 from position_assignments where position_id = v_position_id)
|
||||
into v_hat_historie;
|
||||
|
||||
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
|
||||
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
|
||||
-- Personalakte klafft eine Lücke.
|
||||
if v_hat_historie then
|
||||
update om_positions set valid_to = current_date where id = v_position_id;
|
||||
else
|
||||
delete from om_positions where id = v_position_id;
|
||||
end if;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(),
|
||||
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
|
||||
v_label, null);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function next_position_number() to anon, authenticated, service_role;
|
||||
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
|
||||
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;
|
||||
@@ -451,7 +451,6 @@ function newHireBase(jobTitle: string) {
|
||||
|
||||
const employees: EmployeeRow[] = [];
|
||||
const history: HistoryRow[] = [];
|
||||
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||
|
||||
function finalizeEmployee(
|
||||
base: ReturnType<typeof newHireBase>,
|
||||
@@ -749,8 +748,6 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
|
||||
// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die
|
||||
// Fremdschlüsselprüfung erst nach dem Statement auswertet.
|
||||
const WIPE_ORDER = [
|
||||
"reorg_moves",
|
||||
"reorg_scenarios",
|
||||
"pending_org_changes",
|
||||
"hire_drafts",
|
||||
"employee_notes",
|
||||
|
||||
Reference in New Issue
Block a user