Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
aggregateEvents,
|
||
aggregateReport,
|
||
deriveStatusAsOf,
|
||
groupKeyFor,
|
||
groupKeysFor,
|
||
measureValue,
|
||
MEASURE_LABELS,
|
||
type OrgLookups,
|
||
type ReportEmployee,
|
||
type ReportEvent,
|
||
} from "@/lib/reports";
|
||
|
||
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||
return {
|
||
id: "1",
|
||
first_name: "Maria",
|
||
last_name: "Gruber",
|
||
job_title: "Maschinenbediener:in",
|
||
org_unit_id: "team-1",
|
||
location_id: "loc-1",
|
||
status: "Aktiv",
|
||
employment_type: "Vollzeit",
|
||
contract_type: "unbefristet",
|
||
entry_date: "2020-01-01",
|
||
exit_date: null,
|
||
weekly_hours: 38.5,
|
||
source: "Extern",
|
||
paygrade: "B",
|
||
birth_date: "1990-01-01",
|
||
gender: "w",
|
||
worker_type: "Angestellte:r",
|
||
collective_agreement: "Handel",
|
||
work_days: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||
is_betriebsrat: false,
|
||
has_dienstwagen: false,
|
||
is_laterale_fuehrung: false,
|
||
is_c_level: false,
|
||
dependents_count: 0,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// Alle drei sind über die Einheit geschlüsselt, in der die Person sitzt:
|
||
// "team-1" hängt unter der Abteilung Fertigung im Bereich Produktion.
|
||
// "div-1" ist der Bereich selbst — dort sitzt nur die Bereichsleitung, die
|
||
// weder Abteilung noch Team hat.
|
||
const lookups: OrgLookups = {
|
||
divisionName: new Map([
|
||
["team-1", "Produktion"],
|
||
["div-1", "Produktion"],
|
||
]),
|
||
departmentName: new Map([["team-1", "Fertigung"]]),
|
||
teamName: new Map([["team-1", "Montage"]]),
|
||
locationName: new Map([["loc-1", "Wien-Hernals"]]),
|
||
};
|
||
|
||
describe("no salary measure in scope (consolidation §4/§8)", () => {
|
||
it("does not expose an avg_salary measure", () => {
|
||
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
|
||
});
|
||
});
|
||
|
||
describe("groupKeyFor", () => {
|
||
it("resolves division/department/team/location names via lookups", () => {
|
||
const e = emp();
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
|
||
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
|
||
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
|
||
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
|
||
});
|
||
|
||
it("falls back to a dash for department/team for somebody sitting at the division itself", () => {
|
||
// Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher
|
||
// liess sich das nur über team_id = null ausdrücken.
|
||
const e = emp({ org_unit_id: "div-1" });
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
|
||
expect(groupKeyFor(e, "department", lookups)).toBe("–");
|
||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||
});
|
||
|
||
it("falls back to a dash when somebody held no position at all", () => {
|
||
const e = emp({ org_unit_id: null });
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("–");
|
||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||
});
|
||
|
||
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
|
||
const e = emp({ org_unit_id: "ghost", location_id: "ghost" });
|
||
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
|
||
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
|
||
});
|
||
|
||
it("derives entry_year from entry_date", () => {
|
||
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
|
||
});
|
||
|
||
it("resolves the Rolle & Anstellung boolean dimensions as Ja/Nein", () => {
|
||
const e = emp({ is_betriebsrat: true, has_dienstwagen: false, is_laterale_fuehrung: true, is_c_level: false });
|
||
expect(groupKeyFor(e, "betriebsrat", lookups)).toBe("Ja");
|
||
expect(groupKeyFor(e, "dienstwagen", lookups)).toBe("Nein");
|
||
expect(groupKeyFor(e, "laterale_fuehrung", lookups)).toBe("Ja");
|
||
expect(groupKeyFor(e, "c_level", lookups)).toBe("Nein");
|
||
});
|
||
|
||
it("resolves worker_type/collective_agreement directly", () => {
|
||
const e = emp({ worker_type: "Arbeiter:in", collective_agreement: "Süßwaren" });
|
||
expect(groupKeyFor(e, "worker_type", lookups)).toBe("Arbeiter:in");
|
||
expect(groupKeyFor(e, "collective_agreement", lookups)).toBe("Süßwaren");
|
||
});
|
||
|
||
it("resolves has_dependents from dependents_count", () => {
|
||
expect(groupKeyFor(emp({ dependents_count: 0 }), "has_dependents", lookups)).toBe("Nein");
|
||
expect(groupKeyFor(emp({ dependents_count: 2 }), "has_dependents", lookups)).toBe("Ja");
|
||
});
|
||
});
|
||
|
||
describe("groupKeysFor", () => {
|
||
it("returns a single-element array for every ordinary dimension", () => {
|
||
expect(groupKeysFor(emp(), "division", lookups)).toEqual(["Produktion"]);
|
||
});
|
||
|
||
it("returns one key per work day for the weekday dimension", () => {
|
||
expect(groupKeysFor(emp({ work_days: ["Mo", "Mi"] }), "weekday", lookups)).toEqual(["Mo", "Mi"]);
|
||
});
|
||
});
|
||
|
||
describe("measureValue", () => {
|
||
it("returns 0 for an empty row set regardless of measure", () => {
|
||
expect(measureValue([], "headcount")).toBe(0);
|
||
});
|
||
|
||
it("counts headcount as row length", () => {
|
||
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
|
||
expect(measureValue(rows, "headcount")).toBe(3);
|
||
});
|
||
|
||
it("sums FTE as weekly_hours / 38.5", () => {
|
||
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
|
||
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
|
||
});
|
||
|
||
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
|
||
const rows = [
|
||
emp({ employment_type: "Vollzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
emp({ employment_type: "Teilzeit" }),
|
||
];
|
||
expect(measureValue(rows, "parttime_rate")).toBe(75);
|
||
});
|
||
|
||
it("computes female_share as a percentage", () => {
|
||
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
|
||
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
|
||
});
|
||
|
||
it("computes avg_age from birth_date", () => {
|
||
const today = new Date();
|
||
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
|
||
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
|
||
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
|
||
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
|
||
});
|
||
|
||
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
|
||
const rows = [
|
||
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
|
||
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
|
||
];
|
||
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
|
||
});
|
||
|
||
it("computes avg_dependents as the mean dependents_count", () => {
|
||
const rows = [emp({ dependents_count: 0 }), emp({ dependents_count: 2 }), emp({ dependents_count: 4 })];
|
||
expect(measureValue(rows, "avg_dependents")).toBeCloseTo(2, 5);
|
||
});
|
||
});
|
||
|
||
describe("aggregateReport", () => {
|
||
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
|
||
const employees = [
|
||
emp({ id: "1", org_unit_id: "team-1" }),
|
||
emp({ id: "2", org_unit_id: "team-1" }),
|
||
emp({ id: "3", org_unit_id: "team-2" }),
|
||
];
|
||
// Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt
|
||
// unter Produktion, team-2 unter IT.
|
||
const multiDivisionLookups: OrgLookups = {
|
||
...lookups,
|
||
divisionName: new Map([
|
||
["team-1", "Produktion"],
|
||
["team-2", "IT"],
|
||
]),
|
||
};
|
||
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
|
||
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
|
||
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
|
||
for (const row of rows) {
|
||
for (const person of row.people) {
|
||
expect(person).not.toHaveProperty("monthly_salary_gross");
|
||
}
|
||
}
|
||
});
|
||
|
||
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
|
||
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
|
||
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
|
||
expect(rows[0].people).toHaveLength(15);
|
||
});
|
||
|
||
it("adds a split breakdown per group when a split dimension is given", () => {
|
||
const employees = [
|
||
emp({ id: "1", employment_type: "Vollzeit" }),
|
||
emp({ id: "2", employment_type: "Teilzeit" }),
|
||
];
|
||
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
|
||
expect(rows[0].split).toEqual(
|
||
expect.arrayContaining([
|
||
{ key: "Vollzeit", value: 1, count: 1 },
|
||
{ key: "Teilzeit", value: 1, count: 1 },
|
||
])
|
||
);
|
||
});
|
||
|
||
it("reconstructs headcount/avg_age/avg_tenure as of a past Stichtag, not today", () => {
|
||
// Entered 2019, exited 2021 — gone by today, but was active on 2020-06-01.
|
||
const employees = [emp({ id: "1", entry_date: "2019-01-01", exit_date: "2021-01-01", birth_date: "1990-01-01" })];
|
||
const asOf = "2020-06-01";
|
||
const rows = aggregateReport(employees, "headcount", "division", null, lookups, asOf);
|
||
expect(rows[0]).toMatchObject({ key: "Produktion", value: 1, count: 1 });
|
||
expect(measureValue(employees, "avg_age", asOf)).toBe(30);
|
||
expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1);
|
||
});
|
||
|
||
it("counts an employee once per work day for the weekday dimension, and sorts Mo→So instead of by value", () => {
|
||
const employees = [
|
||
emp({ id: "1", work_days: ["Mo", "Di", "Mi", "Do", "Fr"] }),
|
||
emp({ id: "2", work_days: ["Mo", "Mi", "Fr"] }),
|
||
];
|
||
const rows = aggregateReport(employees, "headcount", "weekday", null, lookups);
|
||
expect(rows.map((r) => r.key)).toEqual(["Mo", "Di", "Mi", "Do", "Fr"]);
|
||
expect(rows.find((r) => r.key === "Mo")).toMatchObject({ value: 2, count: 2 });
|
||
expect(rows.find((r) => r.key === "Di")).toMatchObject({ value: 1, count: 1 });
|
||
});
|
||
|
||
it("sorts a weekday split chronologically within each group", () => {
|
||
const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })];
|
||
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
|
||
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
|
||
});
|
||
});
|
||
|
||
describe("deriveStatusAsOf", () => {
|
||
const base = { entry_date: "2020-01-01", exit_date: null as string | null, karenz_start_date: null as string | null, karenz_return_date: null as string | null };
|
||
|
||
it("is Geplant before the entry date", () => {
|
||
expect(deriveStatusAsOf({ ...base, entry_date: "2025-01-01" }, "2024-12-31")).toBe("Geplant");
|
||
});
|
||
|
||
it("is Ausgetreten on/after the exit date", () => {
|
||
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-06-01")).toBe("Ausgetreten");
|
||
expect(deriveStatusAsOf({ ...base, exit_date: "2022-06-01" }, "2022-05-31")).toBe("Aktiv");
|
||
});
|
||
|
||
it("is Karenz within the Karenz window, Aktiv once returned", () => {
|
||
const onKarenz = { ...base, karenz_start_date: "2023-01-01", karenz_return_date: "2023-07-01" };
|
||
expect(deriveStatusAsOf(onKarenz, "2023-03-01")).toBe("Karenz");
|
||
expect(deriveStatusAsOf(onKarenz, "2023-07-01")).toBe("Aktiv");
|
||
expect(deriveStatusAsOf(onKarenz, "2022-12-31")).toBe("Aktiv");
|
||
});
|
||
});
|
||
|
||
describe("aggregateEvents", () => {
|
||
const eventLookups: OrgLookups = {
|
||
...lookups,
|
||
divisionName: new Map([
|
||
["team-1", "Produktion"],
|
||
["team-2", "IT"],
|
||
]),
|
||
};
|
||
|
||
function ev(overrides: Partial<ReportEvent> = {}): ReportEvent {
|
||
return {
|
||
employee_id: "1",
|
||
first_name: "Maria",
|
||
last_name: "Gruber",
|
||
job_title: "Maschinenbediener:in",
|
||
org_unit_id: "team-1",
|
||
location_id: "loc-1",
|
||
event_date: "2026-03-01",
|
||
event_type: "Eintritt",
|
||
description: "Eintritt als Maschinenbediener:in",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => {
|
||
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", org_unit_id: "team-2" })];
|
||
const rows = aggregateEvents(events, "event_type", null, eventLookups);
|
||
expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 });
|
||
expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 });
|
||
});
|
||
|
||
it("groups by the affected employee's org unit and preserves the event description on drill-down", () => {
|
||
const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })];
|
||
const rows = aggregateEvents(events, "division", null, eventLookups);
|
||
const produktion = rows.find((r) => r.key === "Produktion")!;
|
||
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });
|
||
});
|
||
});
|