**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL. Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert aus §1.1. Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei — das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum. **Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte. employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang an das Richtige; falsch war die Ableitung in der Anwendung. Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab. Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht betroffen. **Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht "Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen, das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf. Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb, Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster Kontrast 5.49:1. Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war. Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser nicht gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
14 KiB
TypeScript
350 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",
|
||
...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");
|
||
});
|
||
});
|