Last step moved the four part-time arrangements out of the absence list and recorded the reason in the history text. That answered "what happened" but not "who is in one right now", and the profile showed nothing at all. So it becomes a real field: teilzeit_art, with an optional end date. The objection I raised then still holds — a state goes stale, because nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is the answer to it: with an end date a report decides for itself what is still running instead of trusting that someone maintained the row. Left empty it means "open end", which is an honest thing to say. It runs through the ordinary change machinery rather than beside it. It sits in app_feld_karte, so it shows up in the history as a field with before and after, and can be corrected there like any other. The description suffix from last step is gone — writing the same thing twice is how two versions start disagreeing. Reporting: filter by variant, by "in one at all", and by when it ends; group headcount by variant, where the absence of one reads "Keine" rather than a dash, because in a report that is an answer and not a gap. Plus columns in the export and the import. One gap found while rehearsing, and only because the probe happened to pick a return date in the future: a scheduled return carries its payload through pending_org_changes, and that payload did not include the variant. Someone would have come back on reduced hours in April with the reason gone. The daily run now carries it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
13 KiB
TypeScript
329 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,
|
||
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");
|
||
});
|
||
});
|
||
|
||
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" });
|
||
});
|
||
});
|
||
|
||
// 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");
|
||
});
|
||
});
|