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:
154
tests/unit/om-reporting.test.ts
Normal file
154
tests/unit/om-reporting.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user