-- Die Beförderung kann die Planstelle wechseln — und der Nachtlauf führt -- beides aus. -- -- ═══ 1. Beförderung: dieselbe Stelle oder eine neue ════════════════ -- -- Bisher änderte eine Beförderung nur Bezeichnung und Verwendungsgruppe; die -- Planstelle blieb, wo sie war. Gewünscht ist die Wahl: dieselbe Planstelle -- oder eine neue, und dann aus den freien — wie die Zielplanstelle bei der -- Versetzung. -- -- Die neue Stelle muss frei sein, mit derselben Meldung wie dort. Ein -- Sonderfall ist erlaubt: die Person sitzt bereits darauf. Dann ist es keine -- Bewegung, sondern eine Beförderung auf der eigenen Stelle, und ein „bereits -- besetzt" wäre eine Falschaussage über sie selbst. -- -- `job_title` kommt aus dem Formular und nicht aus dem Job der Zielstelle: -- die Spalte darf abweichen (so steht es auch in der Importvorlage), und die -- Oberfläche schlägt den Titel der gewählten Stelle ohnehin vor. -- -- Nebenbei zwei Altlasten dieser Funktion, die beim Neuschreiben ohnehin -- anfielen: `auth.uid()` weicht `app_current_user_id()` — die Anwendungsrolle -- darf das Schema `auth` nicht —, und der `search_path` steht jetzt fest. -- -- ═══ 2. Der Nachtlauf bewegte niemanden ════════════════════════════ -- -- Beim Aufräumen dieser Stelle ist ein zweiter, älterer Fehler aufgefallen. -- -- `transfer_employee` schreibt für ein künftiges Datum einen Eintrag in -- pending_org_changes mit `target_position_id`. Der Nachtlauf hat dort zur -- Umstellung auf Planstellen (20260727120200) die Besetzung umgehängt. In der -- zuletzt gültigen Fassung stand nur noch: -- -- update employees set job_title = coalesce(payload->>'new_title', ...) -- -- also die Fassung von **vor** jener Umstellung — und `new_title` kommt in -- diesem payload gar nicht vor. Eine auf später datierte Versetzung wurde am -- Stichtag auf `applied` gesetzt und bewegte niemanden: die Person blieb auf -- ihrer alten Planstelle, im Organigramm unverändert, ohne dass irgendwo eine -- Meldung entstand. -- -- Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage -- neu geschrieben. Dieselbe Falle wie bei der Gruppe `role` -- (20260915120000) und bei der Anmerkung zum Austritt (20260923180000). Die -- Selbstprüfung unten prüft deshalb nicht mehr nur das neue Verhalten, -- sondern beide Zweige auf das, was sie anfassen müssen. create or replace function promote_employee(payload jsonb) returns void language plpgsql set search_path to 'public', 'pg_temp' as $$ declare v_employee_id uuid := (payload->>'employee_id')::uuid; v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date); v_position uuid := nullif(payload->>'target_position_id', '')::uuid; v_old_paygrade paygrade_type; v_name text; v_details text; v_besetzt uuid; v_nummer text; begin perform require_hr_admin(); select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id; if v_position is not null then select position_number into v_nummer from om_positions where id = v_position; if v_nummer is null then raise exception 'Die gewählte Planstelle existiert nicht.'; end if; -- Wie bei der Versetzung. Die eigene Stelle ist ausgenommen: darauf sitzt -- die Person schon, und das ist kein Hindernis für ihre Beförderung. select pa.employee_id into v_besetzt from position_assignments pa where pa.position_id = v_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; end if; v_details := 'Neue Position: ' || (payload->>'new_title'); if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then v_details := v_details || ', neue Verwendungsgruppe: ' || (payload->>'new_paygrade'); end if; if v_nummer is not null then v_details := v_details || ', neue Planstelle ' || v_nummer; end if; if v_effective_date <= current_date then update employees set job_title = payload->>'new_title', paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade) where id = v_employee_id; -- Nur, wenn sich die Stelle wirklich ändert: sonst entstünde eine -- Besetzung, die am selben Tag endet und beginnt, und die Historie der -- Planstelle hätte eine Naht ohne Anlass. if v_position is not null and v_besetzt is distinct from v_employee_id then update position_assignments set valid_to = v_effective_date where employee_id = v_employee_id and valid_to is null; insert into position_assignments (position_id, employee_id, valid_from) values (v_position, v_employee_id, v_effective_date); end if; else insert into pending_org_changes (employee_id, change_type, effective_date, payload) values (v_employee_id, 'promotion', v_effective_date, jsonb_build_object( 'new_title', payload->>'new_title', 'new_paygrade', payload->>'new_paygrade', 'target_position_id', v_position)); end if; insert into employee_history (employee_id, event_date, event_type, description) values (v_employee_id, v_effective_date, 'Beförderung', v_details); insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) values (app_current_user_id(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details); end; $$; grant execute on function public.promote_employee(payload jsonb) to alpenwerk_app; CREATE OR REPLACE FUNCTION public.apply_due_pending_changes() RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'pg_temp' AS $function$ declare v_rec record; v_count integer := 0; v_role jsonb; 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 -- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen -- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt -- — die Fassung von vor der Umstellung auf Planstellen. Eine auf -- später datierte Versetzung galt damit am Stichtag als angewendet -- und bewegte niemanden. 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; -- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig -- stünde am Stichtag der neue Titel an der Person und sie säsze -- weiterhin auf der alten Stelle — im Organigramm unverändert. if nullif(v_rec.payload->>'target_position_id', '') is not null 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); end if; 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), -- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am -- Stichtag mit reduzierten Stunden zurück, und der Grund dafür -- wäre verschwunden. teilzeit_art = case when v_rec.payload ? 'teilzeit_art' then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end, teilzeit_bis = case when v_rec.payload ? 'teilzeit_art' then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end where id = v_rec.employee_id; elsif v_rec.change_type = 'contract_change' then v_role := v_rec.payload->'role'; 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 = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end, nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality), address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end, postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end, city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end, address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end, email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end, phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end, company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end, 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), contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date' then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end, worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type), mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart), source = coalesce((nullif(v_role->>'source', ''))::source_type, source), collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement), work_days = case when v_role ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}') else work_days end, is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat), has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen), is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung), is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level), dienstwagen_art = case when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner') else null end, teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end, teilzeit_bis = case when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date else teilzeit_bis end, hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel), aufenthaltstitel_bis = case when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then case when v_rec.payload->'person' ? 'aufenthaltstitel_bis' then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date else aufenthaltstitel_bis end else null end, has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz), kuendigungsschutz_grund = case when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end else null end, kuendigungsschutz_ab = case when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end else null end, kuendigungsschutz_bis = case when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end else null end, ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert), behinderung_grad = case when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end else null end, behinderung_ab = case when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end else null end, behinderung_bis = case when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end else null end 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')::text, 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; $function$; grant execute on function public.apply_due_pending_changes() to alpenwerk_app; -- Selbstprüfung. do $$ declare v_pro text := pg_get_functiondef('public.promote_employee(jsonb)'::regprocedure); v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure); begin if v_pro not like '%target_position_id%' then raise exception 'promote_employee kennt die Zielplanstelle nicht.'; end if; if v_pro like '%auth.uid()%' then raise exception 'promote_employee ruft wieder auth.uid() — die Anwendungsrolle darf das Schema auth nicht.'; end if; if position('SET search_path' in v_pro) = 0 then raise exception 'promote_employee hat keinen festen search_path.'; end if; -- Beide Zweige müssen die Besetzung anfassen. Genau das ist hier schon -- einmal still verlorengegangen; eine Prüfung auf „kommt vor" reicht -- nicht, weil die Funktion an anderer Stelle ohnehin damit arbeitet. if v_lauf not like '%target_position_id%' then raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht — geplante Versetzungen bewegten niemanden.'; end if; if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um (Versetzung und Befoerderung).'; end if; end $$;