-- Kostenstellen -- -- Die Kostenstelle hing bisher nirgends. Damit war keine einzige Auswertung -- nach Personalkosten möglich, und eine offene Planstelle konnte nicht sagen, -- wessen Budget sie belastet. -- -- Sie hängt an der **Planstelle**, nicht an der Person: der Sitz kostet Geld, -- auch wenn niemand darauf sitzt — genau das ist die Frage bei einer Vakanz. -- Und sie hängt nicht an der Organisationseinheit, obwohl sie sich meistens -- daraus ergibt: eine einzelne Stelle kann auf eine fremde Kostenstelle -- kontiert sein (Projekt, Umlage, geteilte Funktion), ohne dass die Einheit -- wechselt. -- -- Als eigene Zuordnungstabelle mit Zeitraum, nicht als Spalte. Eine Umkontier- -- ung ist ein Ereignis mit Stichtag: die Kosten des Vorjahres müssen dort -- bleiben, wo sie angefallen sind. Als Spalte würde jede Änderung rückwirkend -- jede alte Auswertung verändern — und das merkt niemand. -- -- Halboffene Zeiträume [valid_from, valid_to), wie bei position_assignments -- und om_positions. In SAP OM ist das die Verknüpfung A011 (Kontierung). create table if not exists cost_centers ( id uuid primary key default gen_random_uuid(), code text not null unique, name text not null, -- Die verantwortliche Einheit. Nur ein Hinweis, keine Ableitung: die -- Zuordnung einer Planstelle bleibt die der Planstelle. org_unit_id uuid references org_units(id) on delete set null, valid_from date not null default current_date, valid_to date, created_at timestamptz not null default now(), constraint chk_cost_center_range check (valid_to is null or valid_to > valid_from) ); create table if not exists position_cost_centers ( id uuid primary key default gen_random_uuid(), position_id uuid not null references om_positions(id) on delete cascade, cost_center_id uuid not null references cost_centers(id), valid_from date not null, valid_to date, created_at timestamptz not null default now(), constraint chk_pcc_range check (valid_to is null or valid_to > valid_from) ); create index if not exists position_cost_centers_position_id_idx on position_cost_centers (position_id); create index if not exists position_cost_centers_cost_center_id_idx on position_cost_centers (cost_center_id); -- Eine Planstelle wird zu einem Zeitpunkt auf genau eine Kostenstelle -- kontiert. Eine Kostenteilung wäre etwas anderes und bräuchte einen Anteil. create unique index if not exists position_cost_centers_one_current on position_cost_centers (position_id) where valid_to is null; -- RLS schaltet der Ereignis-Trigger ensure_rls automatisch ein; ohne Policy -- käme aus beiden Tabellen nichts zurück. drop policy if exists cost_centers_hr_all on cost_centers; create policy cost_centers_hr_all on cost_centers for all using (is_hr_user()) with check (is_hr_user()); drop policy if exists position_cost_centers_hr_all on position_cost_centers; create policy position_cost_centers_hr_all on position_cost_centers for all using (is_hr_user()) with check (is_hr_user()); -- ── Bestand: Kostenstellen anlegen und alle Planstellen kontieren ────── -- -- Eine Kostenstelle je Gesellschaft, Bereich und Abteilung — Teams tragen -- keine eigene, sie kontieren auf ihre Abteilung. Das ist die übliche Tiefe: -- ein Team ist eine Führungsspanne, kein Budgettopf. -- -- Die Nummer wird aus der Organisationsnummer abgeleitet (die ersten fünf -- Stellen), damit sie nachvollziehbar ist und nicht geraten wirkt. insert into cost_centers (code, name, org_unit_id, valid_from) select left(o.org_number, 5), o.name, o.id, o.valid_from from org_units o where o.unit_type in ('Gesellschaft', 'Bereich', 'Abteilung') and not exists (select 1 from cost_centers k where k.org_unit_id = o.id) on conflict (code) do nothing; -- Jede Planstelle auf die Kostenstelle der nächsten Einheit über ihr, die -- eine hat — sie selbst zuerst. with recursive kette as ( select p.id as position_id, p.org_unit_id as unit_id, p.valid_from, 0 as tiefe from om_positions p where not exists (select 1 from position_cost_centers z where z.position_id = p.id) union all select k.position_id, o.parent_id, k.valid_from, k.tiefe + 1 from kette k join org_units o on o.id = k.unit_id where o.parent_id is not null and not exists (select 1 from cost_centers c where c.org_unit_id = k.unit_id) ), treffer as ( select distinct on (k.position_id) k.position_id, c.id as cost_center_id, k.valid_from from kette k join cost_centers c on c.org_unit_id = k.unit_id order by k.position_id, k.tiefe ) insert into position_cost_centers (position_id, cost_center_id, valid_from) select position_id, cost_center_id, valid_from from treffer; -- ── Umkontieren ──────────────────────────────────────────────────────── -- -- Zum Stichtag: die laufende Zuordnung wird beendet, die neue beginnt. Kein -- UPDATE der alten Zeile — sonst wäre die Vergangenheit nachträglich anders. create or replace function set_position_cost_center(payload jsonb) returns void language plpgsql security definer set search_path to 'public', 'pg_temp' as $function$ declare v_position_id uuid := (payload->>'position_id')::uuid; v_cost_center_id uuid := (payload->>'cost_center_id')::uuid; v_ab date := coalesce(nullif(payload->>'valid_from', '')::date, current_date); v_position om_positions%rowtype; v_laufend position_cost_centers%rowtype; v_neu cost_centers%rowtype; v_alt text; v_label text; begin perform require_hr_admin(); select * into v_position from om_positions where id = v_position_id; if not found then raise exception 'Die Planstelle existiert nicht.'; end if; select * into v_neu from cost_centers where id = v_cost_center_id; if not found then raise exception 'Die Kostenstelle existiert nicht.'; end if; if v_neu.valid_to is not null and v_neu.valid_to <= v_ab then raise exception 'Die Kostenstelle % ist zum % nicht mehr gültig.', v_neu.code, v_ab; end if; -- Vor dem Bestehen der Planstelle gibt es nichts zu kontieren. if v_ab < v_position.valid_from then raise exception 'Die Planstelle besteht erst ab %.', v_position.valid_from; end if; select * into v_laufend from position_cost_centers where position_id = v_position_id and valid_to is null for update; if found then if v_laufend.cost_center_id = v_cost_center_id then raise exception 'Diese Planstelle ist bereits auf % kontiert.', v_neu.code; end if; -- Ein gleichtägiger Wechsel würde einen Zeitraum der Länge null erzeugen; -- chk_pcc_range verbietet das, und zu Recht: er hätte nie gegolten. if v_ab <= v_laufend.valid_from then raise exception 'Die laufende Kontierung gilt erst ab %. Der Wechsel muss danach liegen.', v_laufend.valid_from; end if; select code into v_alt from cost_centers where id = v_laufend.cost_center_id; update position_cost_centers set valid_to = v_ab where id = v_laufend.id; end if; insert into position_cost_centers (position_id, cost_center_id, valid_from) values (v_position_id, v_cost_center_id, v_ab); select j.title || ' (' || o.name || ')' into v_label from om_positions p join jobs j on j.id = p.job_id join org_units o on o.id = p.org_unit_id where p.id = v_position_id; insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes) values (app_current_user_id(), current_actor_name(), 'Kostenstelle geändert', v_label, coalesce('Von ' || v_alt || ' ', '') || 'auf ' || v_neu.code || ' — ' || v_neu.name || ', ab ' || v_ab, jsonb_build_array(jsonb_build_object('feld', 'Kostenstelle', 'vorher', v_alt, 'nachher', v_neu.code))); end; $function$; revoke all on function set_position_cost_center(jsonb) from public; grant execute on function set_position_cost_center(jsonb) to public;