Files
alpenwerk-hr/tests/unit/reports.test.ts
Andrei Laas 3664b9a435
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m48s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Neuer Slogan, und Hay-Grades stehen als Leiter statt nach Haeufigkeit
Auf der Anmeldeseite steht jetzt "Alles im Blick. Alles Manner." statt des
bisherigen Satzes.

Die Berichte sortieren Gruppen nach der Kennzahl, gross zuerst. Bei Bereich
oder Standort ist das die Antwort auf die Frage, die der Bericht stellt. Bei
einer Leiter ist es keine: HG09 vor HG10 ist die Reihenfolge, in der die Werte
sind, und nach Haeufigkeit umgestellt liest sie sich als Zufall.

Die Sonderbehandlung, die es fuer Wochentage schon gab, ist dafuer zu einer
Liste "Dimensionen mit eigener Reihenfolge" verallgemeinert -- Wochentage und
Hay-Grades stehen darin, und die naechste Leiter ist ein Eintrag statt einer
dritten Verzweigung. Gilt damit auch fuer die Aufschluesselung innerhalb einer
Gruppe und fuer die Spalten im Berichtsexport, die dieselbe Funktion nutzen.

Der Generic Grade steht vor HG09: er ist keine Stufe, sondern ihr Fehlen, und
vor der niedrigsten faellt das am wenigsten als Aussage auf.
2026-09-29 18:26:38 +02:00

375 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "HG13",
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,
teilzeit_art: null,
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"]);
});
it("sorts Hay-Grades aufsteigend statt nach Häufigkeit", () => {
// Die Leiter ist die Aussage: HG09 unten, HG20 oben. Nach Kennzahl
// sortiert stünde hier HG13 vorn, weil dort zufällig zwei Personen
// sitzen — und die Reihenfolge läse sich als Zufall.
const employees = [
emp({ id: "1", paygrade: "HG20" }),
emp({ id: "2", paygrade: "HG09" }),
emp({ id: "3", paygrade: "HG13" }),
emp({ id: "4", paygrade: "HG13" }),
emp({ id: "5", paygrade: "HG19P" }),
emp({ id: "6", paygrade: "HG19" }),
];
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
expect(rows.map((r) => r.key)).toEqual(["HG09", "HG13", "HG19", "HG19P", "HG20"]);
});
it("stellt den Generic Grade vor die unterste Stufe", () => {
// Er ist keine Stufe, sondern ihr Fehlen. Zwischen HG13 und HG14 stünde
// er wie eine.
const employees = [emp({ id: "1", paygrade: "HG13" }), emp({ id: "2", paygrade: "-" })];
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
expect(rows.map((r) => r.key)).toEqual(["-", "HG13"]);
});
});
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");
});
// Der No-Show (Migration 20260814100000): eingestellt, nie erschienen, der
// Austritt wird noch vor dem Eintrittstag verbucht. Vorher gewann hier
// „Geplant", weil der Eintritt zuerst geprüft wurde — die Liste zeigte
// „Ausgetreten" und der Filter „Geplant" fand dieselbe Person trotzdem.
it("is Ausgetreten when the exit is done, even with the entry still ahead", () => {
const noShow = { ...base, entry_date: "2026-10-01", exit_date: "2026-09-10" };
expect(deriveStatusAsOf(noShow, "2026-09-15")).toBe("Ausgetreten");
});
it("stays Geplant while the entry is ahead and nothing has been taken back", () => {
expect(deriveStatusAsOf({ ...base, entry_date: "2026-10-01" }, "2026-09-15")).toBe("Geplant");
});
// Ein Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht
// zurück: am Stichtag ist beides Zukunft, und geplant ist die Person
// weiterhin.
it("stays Geplant when both entry and exit are still ahead", () => {
const geplantMitEnde = { ...base, entry_date: "2026-10-01", exit_date: "2026-12-31" };
expect(deriveStatusAsOf(geplantMitEnde, "2026-09-15")).toBe("Geplant");
});
});
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",
subtype: null,
...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" });
});
});
// Auswertbar sein war der Zweck: „wer ist gerade in Bildungsteilzeit" liess
// sich vorher nicht beantworten, weil die Variante nur im Text der Historie
// stand.
describe("Gruppierung nach Teilzeitvariante", () => {
it("gruppiert nach der Variante", () => {
expect(groupKeyFor(emp({ teilzeit_art: "Bildungsteilzeit" }), "teilzeit_art", lookups)).toBe("Bildungsteilzeit");
expect(groupKeyFor(emp({ teilzeit_art: "Elternteilzeit" }), "teilzeit_art", lookups)).toBe("Elternteilzeit");
});
it("nennt das Fehlen „Keine“ statt eines Gedankenstrichs", () => {
// In einer Auswertung ist „keine Teilzeit" eine Aussage, kein Fehlen.
expect(groupKeyFor(emp({ teilzeit_art: null }), "teilzeit_art", lookups)).toBe("Keine");
});
});