Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).
Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.
StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.
Dazu drei Stellen, die an derselben Spalte hingen:
* Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
* Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
Seite steht.
* Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
die Spalte und damit anders als die Liste, auf die sie verlinkt.
Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from "kysely";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { Schema } from "@/lib/db/schema";
|
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
|
import { deriveStatusAsOf } from "@/lib/reports";
|
|
import type { EmploymentStatus } from "@/lib/types";
|
|
|
|
// Die Liste filtert in der Datenbank, weil sie dort auch blättert. Der Filter
|
|
// ist damit die einzige Stelle, an der der Status *nicht* von
|
|
// deriveStatusAsOf() kommt, sondern von einer zweiten Fassung derselben Regel
|
|
// in SQL. Zwei Fassungen driften, und diese hier taten es: „Geplant" fragte
|
|
// nur nach dem Eintrittsdatum und zählte die No-Shows mit — Leute mit einem
|
|
// Eintritt in der Zukunft, deren Austritt längst verbucht war.
|
|
//
|
|
// tests/integration/employee-status-filter.test.ts hält die beiden gegen eine
|
|
// echte Datenbank, läuft aber mangels Seed nicht in der CI. Deshalb hier
|
|
// zweierlei ohne Datenbank: die erzeugte Abfrage lesen, und die
|
|
// TypeScript-Ableitung an denselben Fällen prüfen.
|
|
|
|
const db = new Kysely<Schema>({
|
|
dialect: {
|
|
createAdapter: () => new PostgresAdapter(),
|
|
createDriver: () => new DummyDriver(),
|
|
createIntrospector: (d) => new PostgresIntrospector(d),
|
|
createQueryCompiler: () => new PostgresQueryCompiler(),
|
|
},
|
|
});
|
|
|
|
const STICHTAG = "2026-09-15";
|
|
|
|
/** Die where-Klausel, die für eine Statusauswahl herauskommt. */
|
|
function bedingung(statuses: EmploymentStatus[]): string {
|
|
return db
|
|
.selectFrom("employees")
|
|
.select("id")
|
|
.where((eb) => derivedStatusFilter(eb, statuses, STICHTAG) ?? eb.val(true))
|
|
.compile()
|
|
.sql.replace(/^.*? where /s, "")
|
|
.replace(/\s+/g, " ");
|
|
}
|
|
|
|
describe("derivedStatusFilter — Geplant", () => {
|
|
it("fragt nach dem Eintritt in der Zukunft", () => {
|
|
expect(bedingung(["Geplant"])).toContain('"entry_date" >');
|
|
});
|
|
|
|
// Die eigentliche Zusicherung: ohne diese Hälfte war der Filter falsch.
|
|
it("schliesst aus, wessen Austritt schon vollzogen ist", () => {
|
|
expect(bedingung(["Geplant"])).toContain('"exit_date" <=');
|
|
});
|
|
|
|
// Und ohne *diese* Hälfte blieb er wirkungslos. Ein Nichtantritt trägt als
|
|
// Austrittsdatum den Eintrittstag; liegt der in der Zukunft, ist auch der
|
|
// Austritt grösser als der Stichtag. Ein Vergleich, der nur den Stichtag
|
|
// kennt, lässt ihn durch — deshalb muss exit_date auch gegen entry_date
|
|
// stehen.
|
|
it("vergleicht den Austritt auch mit dem Eintritt", () => {
|
|
expect(bedingung(["Geplant"])).toContain('"exit_date" <= "entry_date"');
|
|
});
|
|
});
|
|
|
|
describe("derivedStatusFilter — Ausgetreten", () => {
|
|
it("verlangt ein Austrittsdatum, das erreicht ist", () => {
|
|
const sql = bedingung(["Ausgetreten"]);
|
|
expect(sql).toContain('"exit_date" is not null');
|
|
expect(sql).toContain('"exit_date" <=');
|
|
});
|
|
|
|
it("zählt den Nichtantritt mit, dessen Eintritt noch bevorsteht", () => {
|
|
expect(bedingung(["Ausgetreten"])).toContain('"exit_date" <= "entry_date"');
|
|
});
|
|
});
|
|
|
|
describe("derivedStatusFilter — nicht abgedeckte Auswahl", () => {
|
|
it("filtert lieber gar nicht als falsch", () => {
|
|
// Eine Mischung aus beschäftigt und nicht beschäftigt hat keinen Weg in
|
|
// der Oberfläche; geraten wird dafür nichts.
|
|
expect(derivedStatusFilter({} as never, ["Aktiv", "Ausgetreten"], STICHTAG)).toBeNull();
|
|
expect(derivedStatusFilter({} as never, [], STICHTAG)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Dieselben Fälle noch einmal gegen die TypeScript-Ableitung: was der Filter
|
|
// findet und was die Liste daneben anzeigt, muss dieselbe Person meinen.
|
|
describe("Filter und Anzeige meinen dasselbe", () => {
|
|
const basis = { karenz_start_date: null, karenz_return_date: null };
|
|
|
|
const faelle: { was: string; e: Parameters<typeof deriveStatusAsOf>[0]; erwartet: EmploymentStatus }[] = [
|
|
{
|
|
was: "No-Show: Eintritt in der Zukunft, Austritt bereits vollzogen",
|
|
e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-09-10" },
|
|
erwartet: "Ausgetreten",
|
|
},
|
|
{
|
|
was: "geplanter Eintritt ohne Austritt",
|
|
e: { ...basis, entry_date: "2026-10-01", exit_date: null },
|
|
erwartet: "Geplant",
|
|
},
|
|
{
|
|
was: "geplanter Eintritt mit Befristung, beides in der Zukunft",
|
|
e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-12-31" },
|
|
erwartet: "Geplant",
|
|
},
|
|
// Der Fall, der nach der ersten Korrektur noch durchrutschte. So und nicht
|
|
// anders legt ihn terminate_employee an: bei „No Show" wird das übergebene
|
|
// Austrittsdatum verworfen und der Eintrittstag eingesetzt.
|
|
{
|
|
was: "Nichtantritt: Austritt am Eintrittstag, beides in der Zukunft",
|
|
e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-10-01" },
|
|
erwartet: "Ausgetreten",
|
|
},
|
|
{
|
|
was: "Nichtantritt in der Vergangenheit",
|
|
e: { ...basis, entry_date: "2026-08-01", exit_date: "2026-08-01" },
|
|
erwartet: "Ausgetreten",
|
|
},
|
|
// Die Gegenprobe: ein einziger Tag Beschäftigung genügt, und es ist kein
|
|
// Nichtantritt mehr. Läge die Grenze bei „<" statt „<=", fiele genau
|
|
// dieser Fall auf die falsche Seite.
|
|
{
|
|
was: "ein Tag Beschäftigung, Austritt am Folgetag des Eintritts",
|
|
e: { ...basis, entry_date: "2026-08-01", exit_date: "2026-08-02" },
|
|
erwartet: "Ausgetreten",
|
|
},
|
|
{
|
|
was: "laufendes Verhältnis",
|
|
e: { ...basis, entry_date: "2020-04-30", exit_date: null },
|
|
erwartet: "Aktiv",
|
|
},
|
|
// Aigner, Christian aus dem Testbestand: Austritt zum 01.09.2026 erfasst,
|
|
// in der Spalte `status` steht bis heute „Aktiv", weil kein Lauf sie
|
|
// nachzieht. Abgeleitet ist er ausgetreten — und danach richtet sich jetzt
|
|
// auch der Chip in der Liste und in der Akte.
|
|
{
|
|
was: "Austritt liegt zurück, Spalte hängt nach",
|
|
e: { ...basis, entry_date: "2020-04-30", exit_date: "2026-09-01" },
|
|
erwartet: "Ausgetreten",
|
|
},
|
|
];
|
|
|
|
for (const { was, e, erwartet } of faelle) {
|
|
it(was, () => {
|
|
expect(deriveStatusAsOf(e, STICHTAG)).toBe(erwartet);
|
|
});
|
|
}
|
|
});
|