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:
2026-07-27 12:47:14 +02:00
parent 2776c33d08
commit 4b9c23472c
6 changed files with 666 additions and 0 deletions

View File

@@ -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<string>();
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();
}
});
});

View File

@@ -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");
});
});