Files
alpenwerk-hr/supabase/migrations/20260816100000_kostenstellen.sql
Maximilian Stubhan e30923e781 Give a position a cost centre
There was none anywhere in the model, so no personnel-cost figure could
be produced at all, and an open position could not say whose budget it
would charge — which is the first question asked about a vacancy.

It hangs on the position, not on the person: the seat costs money even
when nobody sits on it. That is exactly the vacancy case. And not on the
org unit either, although it usually follows from one — a single seat
can be charged elsewhere (project, shared function) without the unit
moving.

As its own dated assignment table rather than a column, because
reassigning is an event with a date. Last year's costs have to stay
where they were incurred; as a column, every change would silently
rewrite every past report. Half-open [valid_from, valid_to), like
position_assignments and om_positions — in SAP OM this is A011.

25 cost centres seeded from the org tree: one per company, division and
department, with teams charging to their department, because a team is a
span of control and not a budget. All 823 positions were assigned from
their own start date, none left over. The number is the first five digits
of the org number, so it can be traced rather than looked up.

Reassignment refuses three things, each checked: the same cost centre
again, a switch on the day the current one started (that period would
never have been in force, and the range constraint says so), and a date
before the position exists.

Verified against the real data, which turned up a defect worth keeping:
a position that starts in the future is charged only from its start, so
asked about today it had no cost centre — and future positions are
exactly what the vacancy list is for. It is now read at the position's
own start date.

Two audit entries from the probe could not be deleted through the
application (the log has no delete policy — correctly), so I removed
them with the admin connection.

Still open, and the reason this is only the first of the three fields I
proposed: location and planned FTE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:57:09 +02:00

172 lines
7.8 KiB
PL/PgSQL

-- 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;