SAP OM: org units, jobs, positions, and a derived reporting line
The org structure was three fixed tables — divisions -> departments -> teams — with people hanging directly off them and a hand-maintained manager_id. The depth was therefore wired into the schema: an Abteilungsleitung could not exist without a migration, and a team directly under a Bereich not at all. That is what this replaces. The SAP OM object types, one table each: O org_units recursive over parent_id C jobs catalogue, so many positions can share a job S om_positions belongs to exactly one org unit P employees existing table A012 om_positions.is_chief "ist Leiter von" A008 position_assignments "Inhaber ist", time-dependent Two consequences worth stating, because they are the point of the exercise: - GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure. Adding a fifth level, or hanging a team straight off a Bereich, becomes a data question rather than a migration. - Nobody hangs off an org unit any more: person -> position -> unit. A vacancy stops being its own concept — it is a position with no current assignment. The reporting line is derived rather than stored: an ordinary position reports to the chief of its own unit, a chief to the chief of the parent unit, and if that chief is vacant or on a long-term absence it keeps climbing. An unfilled Abteilungsleitung therefore needs no special case — it is simply skipped. Both ids come back, formal and acting, so the UI can show a stand-in as a stand-in instead of passing it off as the real manager. The rule exists twice, as om_reporting_lines() in SQL and resolveReportingLines() in TypeScript, because the as-of chart computes it per date in the app and a round trip per date change would buy nothing. Two copies drift silently — the org chart would just show a different manager than the export — so an integration test runs both over the whole roster and requires identical answers, plus that every line terminates at the top. Unit tests cover the rule itself: unfilled levels, several absent levels in a row, nobody above, a chief who also leads the parent unit, and a cycle in parent_id, which is an ordinary column an import could get wrong. Additive so far. The old tables still stand and the app still reads them; the cut-over follows.
This commit is contained in:
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Organisationsmanagement nach SAP-OM-Vorbild.
|
||||
--
|
||||
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
|
||||
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
|
||||
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
|
||||
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
|
||||
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
|
||||
--
|
||||
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
|
||||
--
|
||||
-- O Organisationseinheit org_units (rekursiv über parent_id)
|
||||
-- C Stelle / Job jobs (Katalog)
|
||||
-- S Planstelle positions (gehört zu genau einer O)
|
||||
-- P Person employees (besetzt eine S)
|
||||
--
|
||||
-- A003 "gehört zu" positions.org_unit_id
|
||||
-- A012 "ist Leiter von" positions.is_chief
|
||||
-- A008 "Inhaber ist" position_assignments
|
||||
--
|
||||
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
|
||||
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
|
||||
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
|
||||
|
||||
-- ── O: Organisationseinheit ────────────────────────────────────────
|
||||
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
|
||||
|
||||
create table org_units (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
org_number text not null unique,
|
||||
name text not null,
|
||||
-- Die Hierarchie selbst. Null nur für die Wurzel.
|
||||
parent_id uuid references org_units(id),
|
||||
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
|
||||
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
|
||||
-- und ist bewusst nicht verboten.
|
||||
unit_type org_unit_type not null,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
|
||||
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index on org_units (parent_id);
|
||||
create index on org_units (unit_type);
|
||||
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
|
||||
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
|
||||
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
|
||||
|
||||
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
|
||||
|
||||
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
|
||||
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
|
||||
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
|
||||
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
|
||||
-- konnten und keine Auswertung über Tätigkeiten möglich war.
|
||||
create table jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
code text not null unique,
|
||||
title text not null unique,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
|
||||
|
||||
-- ── S: Planstelle ──────────────────────────────────────────────────
|
||||
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
|
||||
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
|
||||
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
|
||||
-- Eigenschaft der Planstelle, kein eigenes Objekt.
|
||||
create table om_positions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_number text not null unique,
|
||||
org_unit_id uuid not null references org_units(id),
|
||||
job_id uuid not null references jobs(id),
|
||||
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
|
||||
is_chief boolean not null default false,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
|
||||
);
|
||||
|
||||
create index on om_positions (org_unit_id);
|
||||
create index on om_positions (job_id);
|
||||
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
|
||||
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
|
||||
|
||||
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
|
||||
|
||||
-- ── A008: Person besetzt Planstelle ────────────────────────────────
|
||||
create table position_assignments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null references om_positions(id) on delete cascade,
|
||||
employee_id uuid not null references employees(id) on delete cascade,
|
||||
valid_from date not null,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
|
||||
);
|
||||
|
||||
create index on position_assignments (position_id);
|
||||
create index on position_assignments (employee_id);
|
||||
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
|
||||
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
|
||||
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
|
||||
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
|
||||
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
|
||||
|
||||
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
|
||||
|
||||
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
|
||||
alter table org_units enable row level security;
|
||||
alter table jobs enable row level security;
|
||||
alter table om_positions enable row level security;
|
||||
alter table position_assignments enable row level security;
|
||||
|
||||
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;
|
||||
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
|
||||
--
|
||||
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
|
||||
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
|
||||
-- der Organisationsstruktur abweichen, und tat es auch.
|
||||
--
|
||||
-- SAP-OM-Regel, hier eins zu eins:
|
||||
--
|
||||
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
|
||||
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
|
||||
-- an die Leitung der übergeordneten Einheit.
|
||||
--
|
||||
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
|
||||
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
|
||||
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
|
||||
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
|
||||
-- übersprungen.
|
||||
--
|
||||
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
|
||||
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
|
||||
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
|
||||
-- Führungskraft auszugeben.
|
||||
|
||||
create or replace function om_reporting_lines(p_as_of date default current_date)
|
||||
returns table (
|
||||
employee_id uuid,
|
||||
position_id uuid,
|
||||
org_unit_id uuid,
|
||||
is_chief boolean,
|
||||
formal_manager_id uuid,
|
||||
acting_manager_id uuid
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
with recursive
|
||||
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
|
||||
-- gültig sein.
|
||||
holder as (
|
||||
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
|
||||
from position_assignments pa
|
||||
join om_positions p on p.id = pa.position_id
|
||||
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
|
||||
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
|
||||
),
|
||||
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
|
||||
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
|
||||
chief as (
|
||||
select h.org_unit_id, h.employee_id,
|
||||
(e.karenz_start_date is not null
|
||||
and e.karenz_start_date <= p_as_of
|
||||
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
|
||||
from holder h
|
||||
join employees e on e.id = h.employee_id
|
||||
where h.is_chief
|
||||
),
|
||||
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
|
||||
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
|
||||
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
|
||||
ancestry as (
|
||||
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
|
||||
from org_units u
|
||||
union all
|
||||
select a.unit_id, p.id, p.parent_id, a.depth + 1
|
||||
from ancestry a
|
||||
join org_units p on p.id = a.parent_id
|
||||
),
|
||||
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
|
||||
-- sonst die eigene.
|
||||
base as (
|
||||
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
|
||||
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
|
||||
from holder h
|
||||
join org_units u on u.id = h.org_unit_id
|
||||
)
|
||||
select
|
||||
b.employee_id,
|
||||
b.position_id,
|
||||
b.org_unit_id,
|
||||
b.is_chief,
|
||||
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
|
||||
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
|
||||
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
|
||||
-- die Ausgangseinheit eingeschlossen.
|
||||
(
|
||||
select c.employee_id
|
||||
from ancestry a
|
||||
join chief c on c.org_unit_id = a.ancestor_id
|
||||
where a.unit_id = b.base_unit_id
|
||||
and not c.absent
|
||||
and c.employee_id <> b.employee_id
|
||||
order by a.depth
|
||||
limit 1
|
||||
) as acting_manager_id
|
||||
from base b;
|
||||
$$;
|
||||
|
||||
comment on function om_reporting_lines(date) is
|
||||
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
|
||||
|
||||
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;
|
||||
Reference in New Issue
Block a user