diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql new file mode 100644 index 0000000..1dc0467 --- /dev/null +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -0,0 +1,522 @@ +-- 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. +create temporary table om_pos_map ( + employee_id uuid primary key, + position_id uuid not null default gen_random_uuid(), + seq bigint +) on commit drop; + +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 $$; + +-- ═══ 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;