diff --git a/lib/om-reporting.ts b/lib/om-reporting.ts new file mode 100644 index 0000000..b73c386 --- /dev/null +++ b/lib/om-reporting.ts @@ -0,0 +1,85 @@ +// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum. +// +// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei +// Fassungen derselben Regel driften auseinander, deshalb prüft +// tests/integration/om-reporting.test.ts beide gegen denselben Bestand. +// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin +// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je +// Stichtagswechsel nichts brächte. +// +// Regel: +// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der +// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der +// übergeordneten Einheit. +// +// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es +// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine +// Sonderbehandlung — sie wird übersprungen. + +export type OmUnit = { id: string; parentId: string | null }; + +export type OmHolder = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Langzeitabwesend am Stichtag. */ + absent: boolean; +}; + +export type OmReportingLine = { + employeeId: string; + positionId: string; + orgUnitId: string; + isChief: boolean; + /** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */ + formalManagerId: string | null; + /** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */ + actingManagerId: string | null; +}; + +/** + * `units` und `holders` beschreiben den Stand zu genau einem Stichtag — + * gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst + * draußen, damit diese Funktion nur eine Sache tut. + */ +export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] { + const parentOf = new Map(units.map((u) => [u.id, u.parentId])); + const chiefOfUnit = new Map(); + for (const h of holders) { + if (h.isChief) chiefOfUnit.set(h.orgUnitId, h); + } + + return holders.map((h) => { + // Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der + // eigenen — sonst berichtete eine Leitung an sich selbst. + const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId; + + const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null; + + // Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der + // Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche + // Spalte, und ein fehlerhafter Import kann einen Ring erzeugen. + let actingManagerId: string | null = null; + const seen = new Set(); + let unitId: string | null = baseUnitId; + while (unitId && !seen.has(unitId)) { + seen.add(unitId); + const chief = chiefOfUnit.get(unitId); + if (chief && !chief.absent && chief.employeeId !== h.employeeId) { + actingManagerId = chief.employeeId; + break; + } + unitId = parentOf.get(unitId) ?? null; + } + + return { + employeeId: h.employeeId, + positionId: h.positionId, + orgUnitId: h.orgUnitId, + isChief: h.isChief, + formalManagerId: formal?.employeeId ?? null, + actingManagerId, + }; + }); +} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 672f613..724f655 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -18,6 +18,8 @@ export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | " // union (not a string literal) so a future hr_admin/hr_user split, if ever // technically required, is a type-level addition, not a rewrite. export type ProfileRole = "hr"; +/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */ +export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team"; export type HistoryEventType = | "Eintritt" | "Beförderung" @@ -395,6 +397,86 @@ export type Database = { }; // Written exclusively by trg_track_employee_assignment; RLS grants HR // read access only, hence no Insert/Update shapes worth modelling. + // ── SAP-OM-Modell ────────────────────────────────────────── + // O: rekursiv über parent_id, unit_type ist nur ein Etikett. + org_units: NoRelationships & { + Row: { + id: string; + org_number: string; + name: string; + parent_id: string | null; + unit_type: OrgUnitType; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + org_number: string; + name: string; + parent_id?: string | null; + unit_type: OrgUnitType; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + }; + // C: Katalog der Tätigkeiten. + jobs: NoRelationships & { + Row: { id: string; code: string; title: string; created_at: string }; + Insert: { id?: string; code: string; title: string; created_at?: string }; + Update: Partial; + }; + // S: Planstelle. Heisst om_positions, weil `positions` noch die alte + // Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist. + om_positions: { + Row: { + id: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief: boolean; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_number: string; + org_unit_id: string; + job_id: string; + is_chief?: boolean; + valid_from?: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + Relationships: []; + }; + // A008: Person besetzt Planstelle, zeitabhängig. + position_assignments: { + Row: { + id: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to: string | null; + created_at: string; + }; + Insert: { + id?: string; + position_id: string; + employee_id: string; + valid_from: string; + valid_to?: string | null; + created_at?: string; + }; + Update: Partial; + // Einbettung auf die Planstelle, damit die Berichtslinie in einer + // Abfrage geladen werden kann. + Relationships: [{ foreignKeyName: "position_assignments_position_id_fkey"; columns: ["position_id"]; referencedRelation: "om_positions"; referencedColumns: ["id"] }]; + }; employee_assignments: NoRelationships & { Row: { id: string; @@ -435,6 +517,17 @@ export type Database = { apply_reorg: { Args: { payload: Record }; Returns: string }; undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; + om_reporting_lines: { + Args: { p_as_of?: string }; + Returns: { + employee_id: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; + formal_manager_id: string | null; + acting_manager_id: string | null; + }[]; + }; }; }; }; diff --git a/supabase/migrations/20260727120000_sap_om_org_model.sql b/supabase/migrations/20260727120000_sap_om_org_model.sql new file mode 100644 index 0000000..e454623 --- /dev/null +++ b/supabase/migrations/20260727120000_sap_om_org_model.sql @@ -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; diff --git a/supabase/migrations/20260727120100_om_reporting_lines.sql b/supabase/migrations/20260727120100_om_reporting_lines.sql new file mode 100644 index 0000000..d46ccbe --- /dev/null +++ b/supabase/migrations/20260727120100_om_reporting_lines.sql @@ -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; diff --git a/tests/integration/om-reporting.test.ts b/tests/integration/om-reporting.test.ts new file mode 100644 index 0000000..06f385a --- /dev/null +++ b/tests/integration/om-reporting.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { todayIso } from "@/lib/format"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting"; +import { adminClient } from "./helpers"; + +// Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in +// der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei +// Fassungen derselben Regel driften auseinander, und die Abweichung fällt +// niemandem auf — im Organigramm stünde einfach eine andere Führungskraft +// als im Export. Also über den gesamten Bestand gegeneinanderhalten. +describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => { + const asOf = todayIso(); + + async function fromDatabase() { + const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf }); + if (error) throw new Error(error.message); + return data ?? []; + } + + async function fromTypeScript() { + const [{ data: units }, { data: assignments }] = await Promise.all([ + adminClient.from("org_units").select("id, parent_id"), + adminClient + .from("position_assignments") + .select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)") + .lte("valid_from", asOf) + .or(`valid_to.is.null,valid_to.gt.${asOf}`), + ]); + + const { data: employees } = await adminClient + .from("employees") + .select("id, karenz_start_date, karenz_return_date"); + const absentById = new Map( + (employees ?? []).map((e) => [ + e.id, + Boolean( + e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date) + ), + ]) + ); + + const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id })); + const holders: OmHolder[] = (assignments ?? []) + .filter((a) => { + const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null; + return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf); + }) + .map((a) => { + const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean }; + return { + employeeId: a.employee_id, + positionId: a.position_id, + orgUnitId: p.org_unit_id, + isChief: p.is_chief, + absent: absentById.get(a.employee_id) ?? false, + }; + }); + + return resolveReportingLines(omUnits, holders); + } + + it("liefert für jede Person dieselbe formale und tatsächliche Führungskraft", async () => { + const [db, ts] = await Promise.all([fromDatabase(), fromTypeScript()]); + + expect(db.length).toBe(ts.length); + expect(db.length).toBeGreaterThan(0); + + const tsById = new Map(ts.map((l) => [l.employeeId, l])); + const abweichungen = db + .map((row) => { + const mine = tsById.get(row.employee_id); + if (!mine) return `${row.employee_id}: fehlt in der TypeScript-Fassung`; + if (mine.actingManagerId !== row.acting_manager_id) + return `${row.employee_id}: acting DB=${row.acting_manager_id} TS=${mine.actingManagerId}`; + if (mine.formalManagerId !== row.formal_manager_id) + return `${row.employee_id}: formal DB=${row.formal_manager_id} TS=${mine.formalManagerId}`; + return null; + }) + .filter(Boolean); + + expect(abweichungen.slice(0, 10)).toEqual([]); + }); + + it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => { + const db = await fromDatabase(); + const wurzel = db.filter((r) => r.acting_manager_id === null); + expect(wurzel).toHaveLength(1); + }); + + it("erzeugt keine Berichtslinie auf sich selbst", async () => { + const db = await fromDatabase(); + expect(db.filter((r) => r.acting_manager_id === r.employee_id)).toEqual([]); + }); + + it("lässt jede Berichtslinie an der obersten Leitung enden", async () => { + // Ein Ring in den abgeleiteten Linien wäre im Organigramm ein Teilbaum, + // der nie gerendert wird — und niemand würde es merken. + const db = await fromDatabase(); + const managerOf = new Map(db.map((r) => [r.employee_id, r.acting_manager_id])); + for (const start of db) { + const gesehen = new Set(); + let cur: string | null = start.employee_id; + while (cur && !gesehen.has(cur)) { + gesehen.add(cur); + cur = managerOf.get(cur) ?? null; + } + expect(cur, `Ring in der Berichtslinie ab ${start.employee_id}`).toBeNull(); + } + }); +}); diff --git a/tests/unit/om-reporting.test.ts b/tests/unit/om-reporting.test.ts new file mode 100644 index 0000000..9cea39e --- /dev/null +++ b/tests/unit/om-reporting.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting"; + +// Vier Ebenen wie in der Zielstruktur: Gesellschaft -> Bereich -> Abteilung +// -> Team. Die Hierarchie steckt allein in parent_id; unit_type ist ein +// Etikett und für die Ableitung ohne Bedeutung. +const UNITS: OmUnit[] = [ + { id: "gf", parentId: null }, + { id: "bereich", parentId: "gf" }, + { id: "abteilung", parentId: "bereich" }, + { id: "team", parentId: "abteilung" }, +]; + +function holder(employeeId: string, orgUnitId: string, isChief: boolean, absent = false): OmHolder { + return { employeeId, positionId: `pos-${employeeId}`, orgUnitId, isChief, absent }; +} + +function lineFor(employeeId: string, holders: OmHolder[], units: OmUnit[] = UNITS) { + return resolveReportingLines(units, holders).find((l) => l.employeeId === employeeId)!; +} + +describe("Berichtslinie aus dem Organisationsbaum", () => { + const full = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true), + holder("tl", "team", true), + holder("ma", "team", false), + ]; + + it("lässt Mitarbeitende an die Leitung der eigenen Einheit berichten", () => { + expect(lineFor("ma", full).actingManagerId).toBe("tl"); + }); + + it("lässt eine Leitung an die Leitung der übergeordneten Einheit berichten", () => { + expect(lineFor("tl", full).actingManagerId).toBe("al"); + expect(lineFor("al", full).actingManagerId).toBe("bl"); + expect(lineFor("bl", full).actingManagerId).toBe("gf-person"); + }); + + it("gibt der obersten Leitung keine Führungskraft", () => { + const gf = lineFor("gf-person", full); + expect(gf.actingManagerId).toBeNull(); + expect(gf.formalManagerId).toBeNull(); + }); +}); + +describe("unbesetzte Leitung", () => { + // Der eigentliche Grund für die Aufwärtsregel: eine Abteilung ohne + // Leitung soll die Kette nicht abreißen lassen und braucht keine + // Sonderbehandlung im Code. + const ohneAbteilungsleitung = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("tl", "team", true), + holder("ma", "team", false), + ]; + + it("überspringt eine unbesetzte Abteilungsleitung", () => { + expect(lineFor("tl", ohneAbteilungsleitung).actingManagerId).toBe("bl"); + }); + + it("nennt die unbesetzte Ebene auch nicht als formale Leitung", () => { + expect(lineFor("tl", ohneAbteilungsleitung).formalManagerId).toBeNull(); + }); + + it("überspringt mehrere unbesetzte Ebenen hintereinander", () => { + const nurGf = [holder("gf-person", "gf", true), holder("ma", "team", false)]; + expect(lineFor("ma", nurGf).actingManagerId).toBe("gf-person"); + }); + + it("lässt die Führungskraft leer, wenn oberhalb niemand besetzt ist", () => { + const allein = [holder("ma", "team", false)]; + expect(lineFor("ma", allein).actingManagerId).toBeNull(); + }); +}); + +describe("abwesende Leitung", () => { + const teamleitungAbwesend = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true), + holder("tl", "team", true, true), + holder("ma", "team", false), + ]; + + it("hebt die Berichtslinie auf die nächste anwesende Ebene", () => { + expect(lineFor("ma", teamleitungAbwesend).actingManagerId).toBe("al"); + }); + + it("nennt weiterhin die formal zuständige Leitung, damit die Vertretung erkennbar bleibt", () => { + // Ohne das würde die Oberfläche die Vertretung als die echte + // Führungskraft ausgeben. + expect(lineFor("ma", teamleitungAbwesend).formalManagerId).toBe("tl"); + }); + + it("steigt über mehrere abwesende Ebenen hinweg", () => { + const zweiAbwesend = [ + holder("gf-person", "gf", true), + holder("bl", "bereich", true), + holder("al", "abteilung", true, true), + holder("tl", "team", true, true), + holder("ma", "team", false), + ]; + expect(lineFor("ma", zweiAbwesend).actingManagerId).toBe("bl"); + expect(lineFor("ma", zweiAbwesend).formalManagerId).toBe("tl"); + }); + + it("gibt die abwesende Leitung selbst an ihre eigene übergeordnete Ebene", () => { + expect(lineFor("tl", teamleitungAbwesend).actingManagerId).toBe("al"); + }); +}); + +describe("Randfälle", () => { + it("lässt niemanden an sich selbst berichten", () => { + // Eine Leitung, deren übergeordnete Einheit sie ebenfalls führt. + const doppelrolle = [holder("chef", "bereich", true), holder("chef2", "abteilung", true)]; + const units: OmUnit[] = [ + { id: "bereich", parentId: null }, + { id: "abteilung", parentId: "bereich" }, + ]; + expect(lineFor("chef", doppelrolle, units).actingManagerId).toBeNull(); + }); + + it("bricht bei einem Ring in parent_id ab, statt ewig zu laufen", () => { + // parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann + // einen Ring erzeugen. + const ring: OmUnit[] = [ + { id: "a", parentId: "b" }, + { id: "b", parentId: "a" }, + ]; + const holders = [holder("ma", "a", false)]; + expect(() => resolveReportingLines(ring, holders)).not.toThrow(); + expect(lineFor("ma", holders, ring).actingManagerId).toBeNull(); + }); + + it("kommt mit mehreren Teams unter derselben Abteilung zurecht", () => { + const units: OmUnit[] = [ + { id: "abteilung", parentId: null }, + { id: "team-a", parentId: "abteilung" }, + { id: "team-b", parentId: "abteilung" }, + ]; + const holders = [ + holder("al", "abteilung", true), + holder("tl-a", "team-a", true), + holder("tl-b", "team-b", true), + holder("ma-a", "team-a", false), + holder("ma-b", "team-b", false), + ]; + expect(lineFor("ma-a", holders, units).actingManagerId).toBe("tl-a"); + expect(lineFor("ma-b", holders, units).actingManagerId).toBe("tl-b"); + expect(lineFor("tl-b", holders, units).actingManagerId).toBe("al"); + }); +});