Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst.
351 lines
14 KiB
TypeScript
351 lines
14 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,
|
||
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"]);
|
||
});
|
||
});
|
||
|
||
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");
|
||
});
|
||
});
|