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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// technically required, is a type-level addition, not a rewrite.
|
||||||
export type ProfileRole = "hr";
|
export type ProfileRole = "hr";
|
||||||
|
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
|
||||||
|
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||||
export type HistoryEventType =
|
export type HistoryEventType =
|
||||||
| "Eintritt"
|
| "Eintritt"
|
||||||
| "Beförderung"
|
| "Beförderung"
|
||||||
@@ -395,6 +397,86 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||||
// read access only, hence no Insert/Update shapes worth modelling.
|
// 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<Database["public"]["Tables"]["org_units"]["Insert"]>;
|
||||||
|
};
|
||||||
|
// 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<Database["public"]["Tables"]["jobs"]["Insert"]>;
|
||||||
|
};
|
||||||
|
// 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<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||||
|
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<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
|
||||||
|
// 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 & {
|
employee_assignments: NoRelationships & {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -435,6 +517,17 @@ export type Database = {
|
|||||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
apply_due_pending_changes: { Args: Record<string, never>; 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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
@@ -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;
|
||||||
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
@@ -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;
|
||||||
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
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