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