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