-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen. -- -- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie). -- -- Warum ein Schnitt und keine schrittweise Migration: Sobald eine -- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for() -- falsche Ergebnisse. Es sucht die Bereichsleitung über -- "division_id = X and team_id is null and org_level = 1" — eine -- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann -- willkürlich eine von beiden. -- -- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist -- das Organigramm gefüllt. -- -- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt -- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als -- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt -- sind — die Berichtslinie bleibt durchgängig. begin; -- ═══ 1. Organisationseinheiten ═══════════════════════════════════ -- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit -- die Personen, die daran hingen, ihre Einheit behalten. insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01' from divisions where name = 'Geschäftsführung'; -- Falls es sie nicht gab, eine neue Wurzel anlegen. insert into org_units (org_number, name, parent_id, unit_type, valid_from) select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01' where not exists (select 1 from org_units where unit_type = 'Gesellschaft'); insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) select d.id, d.org_number, d.name, (select id from org_units where unit_type = 'Gesellschaft'), 'Bereich', '2000-01-01' from divisions d where d.name <> 'Geschäftsführung'; insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) select dep.id, dep.org_number, dep.name, coalesce((select u.id from org_units u where u.id = dep.division_id), (select id from org_units where unit_type = 'Gesellschaft')), 'Abteilung', '2000-01-01' from departments dep; insert into org_units (id, org_number, name, parent_id, unit_type, valid_from) select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01' from teams t; -- ═══ 2. Job-Katalog ══════════════════════════════════════════════ -- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die -- Titel der neuen Abteilungsleitungen. insert into jobs (code, title) select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title from ( select distinct job_title as title from employees where job_title is not null union select distinct title from positions where title is not null union select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung' ) t on conflict (title) do nothing; -- ═══ 3. Planstellen und Besetzungen ══════════════════════════════ -- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von -- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen -- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen -- doch verschieden. -- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten -- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle -- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt. create temporary table om_pos_map ( employee_id uuid primary key, position_id uuid not null default gen_random_uuid(), seq bigint ); insert into om_pos_map (employee_id, seq) select id, row_number() over (order by org_level, personnel_number) from employees; insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from) select m.position_id, '6' || lpad(m.seq::text, 7, '0'), case when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft') when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft')) else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft')) end, (select j.id from jobs j where j.title = e.job_title), (e.org_level <= 1 or (e.org_level = 2 and e.is_lead)), e.entry_date from employees e join om_pos_map m on m.employee_id = e.id; -- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist -- wieder frei, die Historie bleibt. -- -- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf -- position_assignments verlangt aber valid_to > valid_from. Ein -- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen. insert into position_assignments (position_id, employee_id, valid_from, valid_to) select m.position_id, e.id, e.entry_date, case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end from employees e join om_pos_map m on m.employee_id = e.id; -- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte. insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from) select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'), u.id, (select id from jobs where title = 'Abteilungsleitung ' || u.name), true, current_date from org_units u where u.unit_type = 'Abteilung' and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief); -- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn -- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst -- ohnehin nicht zu. insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from) select p.position_number, p.team_id, (select id from jobs j where j.title = p.title), p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief), p.valid_from from positions p where p.status = 'open' and exists (select 1 from org_units u where u.id = p.team_id); -- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration -- als ein halb überführter Bestand ohne Rückweg. do $$ declare v_fehlend int; begin select count(*) into v_fehlend from employees e where not exists (select 1 from position_assignments pa where pa.employee_id = e.id); if v_fehlend > 0 then raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend; end if; select count(*) into v_fehlend from om_positions where job_id is null; if v_fehlend > 0 then raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend; end if; select count(*) into v_fehlend from org_units u where u.parent_id is null and u.unit_type <> 'Gesellschaft'; if v_fehlend > 0 then raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend; end if; end $$; drop table om_pos_map; -- ═══ 4. Altmodell entfernen ══════════════════════════════════════ -- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind -- transient; halb übersetzt wären sie schlimmer als verworfen. delete from pending_org_changes where status = 'pending'; drop trigger if exists trg_track_employee_assignment on employees; drop function if exists fn_track_employee_assignment(); drop table if exists employee_assignments; -- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und -- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung -- nirgends benutzt und ersatzlos entfernt; die Ableitung über -- om_reporting_lines() tritt an ihre Stelle. drop view if exists employees_directory; -- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in -- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der -- Umstellung der Oberfläche. drop function if exists resolve_manager_for(uuid, boolean, uuid); drop function if exists staff_position_internally(jsonb); drop function if exists create_position(jsonb); drop function if exists delete_position(uuid); drop function if exists apply_reorg(jsonb); drop function if exists undo_reorg(uuid); drop function if exists hire_employee(jsonb); drop function if exists terminate_employee(jsonb); drop function if exists transfer_employee(jsonb); drop function if exists rehire_employee(jsonb); drop function if exists record_karenz_return(jsonb); drop function if exists apply_due_pending_changes(); alter table employees drop column if exists division_id, drop column if exists team_id, drop column if exists manager_id, drop column if exists org_level, drop column if exists is_lead; drop table if exists positions; drop table if exists teams; drop table if exists departments; drop table if exists divisions; -- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════ -- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet. -- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim -- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig, -- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen. create or replace function hire_employee(payload jsonb) returns uuid language plpgsql as $$ declare v_id uuid; v_position_id uuid := (payload->>'position_id')::uuid; v_entry date := (payload->>'entry_date')::date; v_besetzt uuid; begin perform require_hr_admin(); if v_position_id is null then raise exception 'Es muss eine Planstelle angegeben werden.'; end if; select pa.employee_id into v_besetzt from position_assignments pa where pa.position_id = v_position_id and pa.valid_to is null; if v_besetzt is not null then raise exception 'Diese Planstelle ist bereits besetzt.'; end if; insert into employees ( first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone, address, postal_code, city, address_country, location_id, job_title, employment_type, weekly_hours, contract_type, contract_end_date, paygrade, source, status, entry_date, title_prefix, title_suffix, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level ) values ( payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type, (payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone', payload->>'address', payload->>'postal_code', payload->>'city', coalesce(payload->>'address_country', 'Österreich'), (payload->>'location_id')::uuid, (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id), coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'), coalesce((payload->>'weekly_hours')::numeric, 38.5), coalesce((payload->>'contract_type')::contract_type, 'unbefristet'), nullif(payload->>'contract_end_date', '')::date, coalesce((payload->>'paygrade')::paygrade_type, 'B'), coalesce((payload->>'source')::source_type, 'Extern'), case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status, v_entry, coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'), coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'), coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'), coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'), coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'), coalesce((payload->>'is_betriebsrat')::boolean, false), coalesce((payload->>'has_dienstwagen')::boolean, false), coalesce((payload->>'is_laterale_fuehrung')::boolean, false), coalesce((payload->>'is_c_level')::boolean, false) ) returning id into v_id; insert into position_assignments (position_id, employee_id, valid_from) values (v_position_id, v_id, v_entry); insert into employee_history (employee_id, event_date, event_type, description) values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' || (select position_number from om_positions where id = v_position_id)); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (auth.uid(), current_actor_name(), 'Neueinstellung', payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry); return v_id; end; $$; create or replace function terminate_employee(payload jsonb) returns void language plpgsql as $$ declare v_employee_id uuid := (payload->>'employee_id')::uuid; v_exit date := (payload->>'exit_date')::date; v_name text; begin perform require_hr_admin(); select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; update employees set status = case when v_exit <= current_date then 'Ausgetreten' else status end, exit_date = v_exit, exit_reason = payload->>'exit_reason' where id = v_employee_id; -- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt -- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf -- die nächste besetzte Ebene. update position_assignments set valid_to = v_exit where employee_id = v_employee_id and valid_to is null; insert into employee_history (employee_id, event_date, event_type, description) values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')'); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit); end; $$; -- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte -- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben -- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt. create or replace function transfer_employee(payload jsonb) returns void language plpgsql as $$ declare v_employee_id uuid := (payload->>'employee_id')::uuid; v_target_position uuid := (payload->>'target_position_id')::uuid; v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date); v_name text; v_besetzt uuid; begin perform require_hr_admin(); select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; select pa.employee_id into v_besetzt from position_assignments pa where pa.position_id = v_target_position and pa.valid_to is null; if v_besetzt is not null and v_besetzt <> v_employee_id then raise exception 'Die Zielplanstelle ist bereits besetzt.'; end if; if v_effective <= current_date then update position_assignments set valid_to = v_effective where employee_id = v_employee_id and valid_to is null; insert into position_assignments (position_id, employee_id, valid_from) values (v_target_position, v_employee_id, v_effective); update employees set job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position) where id = v_employee_id; else insert into pending_org_changes (employee_id, change_type, effective_date, payload) values (v_employee_id, 'transfer', v_effective, jsonb_build_object('target_position_id', v_target_position)); end if; insert into employee_history (employee_id, event_date, event_type, description) values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' || (select position_number from om_positions where id = v_target_position)); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective); end; $$; create or replace function rehire_employee(payload jsonb) returns void language plpgsql as $$ declare v_employee_id uuid := (payload->>'employee_id')::uuid; v_date date := (payload->>'rehire_date')::date; v_position_id uuid := (payload->>'position_id')::uuid; v_name text; begin perform require_hr_admin(); select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; if v_position_id is null then raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.'; end if; update employees set status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end, entry_date = v_date, exit_date = null, exit_reason = null where id = v_employee_id; insert into position_assignments (position_id, employee_id, valid_from) values (v_position_id, v_employee_id, v_date); insert into employee_history (employee_id, event_date, event_type, description) values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date); end; $$; create or replace function record_karenz_return(payload jsonb) returns void language plpgsql as $$ declare v_employee_id uuid := (payload->>'employee_id')::uuid; v_return_date date := (payload->>'return_date')::date; v_name text; v_employment_type employment_type; v_weekly_hours numeric; v_karenz_start date; v_absence_type text; begin perform require_hr_admin(); select first_name || ' ' || last_name, karenz_start_date, absence_type into v_name, v_karenz_start, v_absence_type from employees where id = v_employee_id; if v_karenz_start is not null and v_return_date <= v_karenz_start then raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start; end if; if payload->>'employment_mode' = 'Vollzeit' then v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5; elsif payload->>'employment_mode' = 'Teilzeit' then v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric; end if; if v_return_date <= current_date then -- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit -- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie -- fällt automatisch von der Vertretung auf ihn zurück. update employees set status = 'Aktiv', karenz_return_date = null, karenz_start_date = null, absence_type = null, employment_type = coalesce(v_employment_type, employment_type), weekly_hours = coalesce(v_weekly_hours, weekly_hours) where id = v_employee_id; else update employees set karenz_return_date = v_return_date where id = v_employee_id; insert into pending_org_changes (employee_id, change_type, effective_date, payload) values (v_employee_id, 'karenz_return', v_return_date, jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours)); end if; insert into employee_history (employee_id, event_date, event_type, description) values (v_employee_id, v_return_date, 'Rückkehr', 'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date); end; $$; create or replace function apply_due_pending_changes() returns int language plpgsql security definer set search_path = public as $$ declare v_rec record; v_count int := 0; begin for v_rec in select * from pending_org_changes where status = 'pending' and effective_date <= current_date order by effective_date, created_at loop if v_rec.change_type = 'transfer' then update position_assignments set valid_to = v_rec.effective_date where employee_id = v_rec.employee_id and valid_to is null; insert into position_assignments (position_id, employee_id, valid_from) values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date); update employees set job_title = ( select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = (v_rec.payload->>'target_position_id')::uuid ) where id = v_rec.employee_id; elsif v_rec.change_type = 'promotion' then 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; elsif v_rec.change_type = 'karenz_start' then update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date, absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type) where id = v_rec.employee_id; elsif v_rec.change_type = 'karenz_return' then update employees set status = 'Aktiv', karenz_return_date = null, karenz_start_date = null, absence_type = null, employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type), weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours) where id = v_rec.employee_id; elsif v_rec.change_type = 'contract_change' then update employees set first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name), last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name), gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender), birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date), sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer), nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), address = coalesce(v_rec.payload->'person'->>'address', address), postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code), city = coalesce(v_rec.payload->'person'->>'city', city), address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country), email = coalesce(v_rec.payload->'person'->>'email', email), phone = coalesce(v_rec.payload->'person'->>'phone', phone), employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type), weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours), contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type) where id = v_rec.employee_id; elsif v_rec.change_type = 'dependent_add' then insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date) values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name', (v_rec.payload->>'relationship')::relationship_type, nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date); elsif v_rec.change_type = 'dependent_remove' then delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid; end if; update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id; v_count := v_count + 1; end loop; return v_count; end; $$; commit;