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:
85
lib/om-reporting.ts
Normal file
85
lib/om-reporting.ts
Normal file
@@ -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<string, OmHolder>();
|
||||
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<string>();
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user