Der Status kommt aus den Daten, nicht aus der Spalte

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.
This commit is contained in:
2026-09-15 22:16:30 +02:00
parent 7a33e493b5
commit 1cbed1a8f5
10 changed files with 240 additions and 44 deletions

View File

@@ -51,6 +51,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
notes={notes ?? []} notes={notes ?? []}
locations={orgMaps.locationList} locations={orgMaps.locationList}
openPositions={openPositions} openPositions={openPositions}
today={today}
/> />
); );
} }

View File

@@ -205,10 +205,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
"entry_date", "entry_date",
"employment_type", "employment_type",
"weekly_hours", "weekly_hours",
"status", // Nicht `status`: der Chip leitet ab, wie der Filter es tut —
// siehe components/ui/StatusChip.tsx. Drei Spalten mehr auf
// fünfzehn Zeilen, keine zusätzliche Rundreise.
"exit_date",
"karenz_start_date",
"karenz_return_date",
"absence_type", "absence_type",
]) ])
.$call((q) => sortiere(q, sortFeld, sortRichtung)) .$call((q) => sortiere(q, sortFeld, sortRichtung, today))
.limit(PAGE_SIZE) .limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE) .offset((page - 1) * PAGE_SIZE)
).as("rows"), ).as("rows"),
@@ -331,7 +336,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span> {e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
</td> </td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} /> <StatusChip employee={e} asOf={today} />
</td> </td>
</tr> </tr>
); );

View File

@@ -11,6 +11,7 @@ import { fmtFullName, tenure } from "@/lib/format";
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding"; import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding"; import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import { deriveStatusAsOf } from "@/lib/reports";
import type { Database } from "@/lib/types"; import type { Database } from "@/lib/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel"; import { KarenzPanel } from "./panels/KarenzPanel";
@@ -54,6 +55,8 @@ type EmployeeDetailProps = {
notes: NoteRow[]; notes: NoteRow[];
locations: Location[]; locations: Location[];
openPositions: OpenPositionResolved[]; openPositions: OpenPositionResolved[];
/** Der Tag, zu dem die Akte gelesen wurde — derselbe wie in loadEmployeeDetail. */
today: string;
}; };
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
@@ -61,7 +64,7 @@ const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offbo
type Tab = (typeof ALLE_TABS)[number]; type Tab = (typeof ALLE_TABS)[number];
export function EmployeeDetail(props: EmployeeDetailProps) { export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
const [tab, setTab] = useState<Tab>("Stammdaten"); const [tab, setTab] = useState<Tab>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null); const [panel, setPanel] = useState<PanelType>(null);
@@ -74,8 +77,15 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
const zeigtOffboarding = gehoertOffboarding(employee); const zeigtOffboarding = gehoertOffboarding(employee);
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding); const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; // Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
const canEditData = employee.status !== "Ausgetreten"; // wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
// keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
// entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
// an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
// weiter zur Verfügung, und „Daten ändern" ebenfalls.
const status = deriveStatusAsOf(employee, today);
const isActive = status === "Aktiv" || status === "Karenz";
const canEditData = status !== "Ausgetreten";
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -92,7 +102,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<h2 className="text-xl font-extrabold text-ink"> <h2 className="text-xl font-extrabold text-ink">
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} {fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
</h2> </h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} /> <StatusChip employee={employee} asOf={today} />
</div> </div>
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p> <p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">

View File

@@ -1,19 +1,46 @@
import { absenceLabel } from "@/lib/absence"; import { absenceLabel } from "@/lib/absence";
import { STATUS_STYLES } from "@/lib/colors"; import { STATUS_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
import type { EmploymentStatus } from "@/lib/types"; import { deriveStatusAsOf } from "@/lib/reports";
type StatusChipProps = { // ── Warum der Status hier abgeleitet und nicht übergeben wird ────────
status: EmploymentStatus; //
entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant // Vorher nahm dieses Bauteil einen fertigen Status entgegen, und beide
// Aufrufer reichten `employees.status` durch — die gespeicherte Spalte. Die
// Liste daneben filtert aber über die Datumsspalten (lib/employee-status-
// filter.ts), und sobald die Spalte nachhängt, widersprechen sich Filter und
// Beschriftung: der Filter „Ausgetreten" fand 48 Personen, von denen mehrere
// als „Aktiv" beschriftet waren.
//
// Nachhängen kann sie regelmässig. terminate_employee (Migration
// 20260814100000) setzt die Spalte nur, wenn das Austrittsdatum nicht in der
// Zukunft liegt — „es gibt keinen Lauf, der das später nachzieht" steht dort
// als Kommentar. Ein Austritt, der zum Zeitpunkt der Erfassung noch bevorstand,
// lässt die Spalte also für immer auf „Aktiv" stehen.
//
// Deshalb nimmt das Bauteil jetzt die Zeile und den Stichtag und leitet
// selbst ab. Die Spalte lässt sich nicht mehr hineinreichen: die zweite
// Quelle ist nicht bloss ungenutzt, es gibt sie hier nicht mehr.
export type StatusChipEmployee = {
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
/** Shown instead of the generic label when the kind of absence is known. */ /** Shown instead of the generic label when the kind of absence is known. */
absenceType?: string | null; absence_type?: string | null;
}; };
export function StatusChip({ status, entryDate, absenceType }: StatusChipProps) { type StatusChipProps = {
// The stored status is still 'Karenz'; absenceLabel maps it to employee: StatusChipEmployee;
// "Langzeitabwesenheit", or to the specific kind when one is recorded. /** Der Tag, zu dem der Status gilt — derselbe, nach dem die Seite filtert. */
const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : absenceLabel(status, absenceType); asOf: string;
};
export function StatusChip({ employee, asOf }: StatusChipProps) {
const status = deriveStatusAsOf(employee, asOf);
// Der abgeleitete Status ist 'Karenz'; absenceLabel macht daraus
// "Langzeitabwesenheit", oder die konkrete Art, wenn eine erfasst ist.
const label = status === "Geplant" ? `Eintritt ${fmtDate(employee.entry_date)}` : absenceLabel(status, employee.absence_type ?? null);
return ( return (
<span <span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`} className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`}

View File

@@ -3,6 +3,7 @@ import { jsonArrayFrom, jsonObjectFrom } from "./db/json";
import { besetzungenAbfrage, pickPlacements } from "./placement"; import { besetzungenAbfrage, pickPlacements } from "./placement";
import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org"; import { buildOrgMaps, orgMapsAbfragen, type OrgEb } from "./org";
import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "./entwuerfe"; import { baueEntwuerfe, entwuerfeAbfrage, type EntwurfZeile } from "./entwuerfe";
import { derivedStatusFilter } from "./employee-status-filter";
import { sichtbareNotizen } from "./notes"; import { sichtbareNotizen } from "./notes";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions";
import type { HistoryEventType } from "./types"; import type { HistoryEventType } from "./types";
@@ -80,7 +81,11 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
eb eb
.selectFrom("employees") .selectFrom("employees")
.select(["id", "first_name", "last_name", "entry_date"]) .select(["id", "first_name", "last_name", "entry_date"])
.where("status", "=", "Geplant") // Abgeleitet, nicht über die Spalte `status`: ein Nichtantritt
// trägt dort sofort „Ausgetreten", ein erfasster künftiger Austritt
// dagegen weiter „Aktiv". Beides liess die Karte „Anstehend" etwas
// anderes zählen als die Liste, auf die sie verlinkt.
.where((e) => derivedStatusFilter(e, ["Geplant"], today) ?? e.val(true))
.where("entry_date", ">=", today) .where("entry_date", ">=", today)
.where("entry_date", "<=", bisIso) .where("entry_date", "<=", bisIso)
.where((e) => e.lit(zeigt("hire"))) .where((e) => e.lit(zeigt("hire")))
@@ -91,6 +96,11 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
.selectFrom("employees") .selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"]) .select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null) .where("exit_date", "is not", null)
// Ein Nichtantritt trägt als Austrittsdatum den Eintrittstag. Liegt
// der in der Zukunft, stand er hier als bevorstehender Austritt —
// vorzubereiten gibt es daran nichts, die Person fängt gar nicht an.
// Dieselbe Überlegung wie bei gehoertOffboarding().
.where((e) => e(e.ref("exit_date"), ">", e.ref("entry_date")))
.where("exit_date", ">=", today) .where("exit_date", ">=", today)
.where("exit_date", "<=", bisIso) .where("exit_date", "<=", bisIso)
.where((e) => e.lit(zeigt("exit"))) .where((e) => e.lit(zeigt("exit")))
@@ -100,7 +110,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
eb eb
.selectFrom("employees") .selectFrom("employees")
.select(["id", "first_name", "last_name", "karenz_return_date"]) .select(["id", "first_name", "last_name", "karenz_return_date"])
.where("status", "=", "Karenz") .where((e) => derivedStatusFilter(e, ["Karenz"], today) ?? e.val(true))
.where("karenz_return_date", "is not", null) .where("karenz_return_date", "is not", null)
.where("karenz_return_date", ">=", today) .where("karenz_return_date", ">=", today)
.where("karenz_return_date", "<=", bisIso) .where("karenz_return_date", "<=", bisIso)

View File

@@ -94,6 +94,29 @@ const bereichAusdruck = sql<string>`(
const standortAusdruck = sql<string>`(select name from locations where id = employees.location_id)`; const standortAusdruck = sql<string>`(select name from locations where id = employees.location_id)`;
/**
* Der Status zum Stichtag als Rang — nicht die Spalte `employees.status`.
*
* Die Liste beschriftet jede Zeile mit dem **abgeleiteten** Status
* (components/ui/StatusChip.tsx) und filtert danach
* (lib/employee-status-filter.ts). Nach der gespeicherten Spalte zu sortieren
* hiesse, die Zeilen nach einem Wert zu ordnen, der nirgends auf der Seite
* steht: eine als „Ausgetreten" beschriftete Person landete mitten unter den
* aktiven, weil in ihrer Spalte noch „Aktiv" steht.
*
* Die Reihenfolge ist die des Aufzählungstyps — Aktiv, Karenz, Geplant,
* Ausgetreten. Das ist der Verlauf eines Dienstverhältnisses und sagt mehr
* als alphabetisch. Die Klauseln stehen in derselben Reihenfolge wie in
* deriveStatusAsOf; wer dort etwas ändert, ändert es auch hier.
*/
const statusRang = (asOf: string) => sql<number>`case
when exit_date is not null and (exit_date <= ${asOf}::date or exit_date <= entry_date) then 4
when entry_date > ${asOf}::date then 3
when karenz_start_date is not null and karenz_start_date <= ${asOf}::date
and (karenz_return_date is null or karenz_return_date > ${asOf}::date) then 2
else 1
end`;
/** /**
* Hängt die Reihenfolge an eine Abfrage über `employees`. * Hängt die Reihenfolge an eine Abfrage über `employees`.
* *
@@ -114,7 +137,8 @@ const standortAusdruck = sql<string>`(select name from locations where id = empl
export function sortiere<O>( export function sortiere<O>(
q: SelectQueryBuilder<Schema, "employees", O>, q: SelectQueryBuilder<Schema, "employees", O>,
feld: Sortierfeld, feld: Sortierfeld,
richtung: Richtung richtung: Richtung,
asOf: string
): SelectQueryBuilder<Schema, "employees", O> { ): SelectQueryBuilder<Schema, "employees", O> {
// Zwei ausgeschriebene Zweige statt einer eingesetzten Richtung: so gerät // Zwei ausgeschriebene Zweige statt einer eingesetzten Richtung: so gerät
// nichts aus der Adresse in die Abfrage, auch nicht als geprüfter Wert. // nichts aus der Adresse in die Abfrage, auch nicht als geprüfter Wert.
@@ -140,10 +164,7 @@ export function sortiere<O>(
case "beschaeftigung": case "beschaeftigung":
return nachName(q.orderBy(ordne(sql.ref("employment_type"))).orderBy(ordne(sql.ref("weekly_hours")))); return nachName(q.orderBy(ordne(sql.ref("employment_type"))).orderBy(ordne(sql.ref("weekly_hours"))));
case "status": case "status":
// Aufzählungstyp: Postgres ordnet nach der Reihenfolge der Werte — return nachName(q.orderBy(ordne(statusRang(asOf))));
// Aktiv, Karenz, Geplant, Ausgetreten. Das ist der Verlauf eines
// Dienstverhältnisses und sagt mehr als alphabetisch.
return nachName(q.orderBy(ordne(sql.ref("status"))));
case "name": case "name":
return nachName(q); return nachName(q);
} }

View File

@@ -13,21 +13,40 @@ import type { EmploymentStatus } from "./types";
// //
// Kept deliberately close to deriveStatusAsOf, clause for clause: // Kept deliberately close to deriveStatusAsOf, clause for clause:
// //
// exit_date <= asOf -> Ausgetreten // exit_date <= asOf ODER exit_date <= entry_date -> Ausgetreten
// entry_date > asOf -> Geplant // entry_date > asOf -> Geplant
// karenz window covers asOf -> Karenz // karenz window covers asOf -> Karenz
// otherwise -> Aktiv // otherwise -> Aktiv
// //
// Die Reihenfolge der ersten beiden ist nicht beliebig: ein abgeschlossener // Die Reihenfolge der ersten beiden ist nicht beliebig: ein abgeschlossener
// Austritt schlaegt einen Eintritt, der noch bevorsteht. Warum das der Fall // Austritt schlaegt einen Eintritt, der noch bevorsteht. Warum das der Fall
// ist, steht bei deriveStatusAsOf. // ist, und warum die erste Klausel zwei Haelften hat, steht bei
// deriveStatusAsOf.
// //
// tests/integration/employee-status-filter.test.ts asserts the two agree // tests/integration/employee-status-filter.test.ts asserts the two agree
// against a real database, which is the only place that can prove it. // against a real database, which is the only place that can prove it.
type Eb = ExpressionBuilder<Schema, "employees">; type Eb = ExpressionBuilder<Schema, "employees">;
/** True once the person has started and has not left yet. */ /**
* Das Verhältnis ist am Stichtag beendet — entweder weil der Austritt
* vorbei ist, oder weil er nicht später liegt als der Eintritt und es
* damit nie einen Tag Beschäftigung gab (Nichtantritt).
*/
function ausgetreten(eb: Eb, asOf: string): Expression<SqlBool> {
return eb.and([
eb("exit_date", "is not", null),
eb.or([eb("exit_date", "<=", asOf), eb("exit_date", "<=", eb.ref("entry_date"))]),
]);
}
/**
* True once the person has started and has not left yet.
*
* Der Nichtantritt braucht hier keine eigene Haelfte: waere exit_date nicht
* groesser als entry_date, muesste zugleich entry_date <= asOf < exit_date <=
* entry_date gelten — das kann keine Zeile erfuellen.
*/
function employed(eb: Eb, asOf: string): Expression<SqlBool> { function employed(eb: Eb, asOf: string): Expression<SqlBool> {
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]); return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
} }
@@ -45,17 +64,20 @@ export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf:
// „Geplant" ist ein Eintritt, der noch bevorsteht **und** nicht // „Geplant" ist ein Eintritt, der noch bevorsteht **und** nicht
// zurueckgenommen wurde. Ohne die zweite Haelfte zaehlte der Filter die // zurueckgenommen wurde. Ohne die zweite Haelfte zaehlte der Filter die
// No-Shows mit: eingestellt, nie erschienen, Austritt vor dem Eintrittstag // Nichtantritte mit: eingestellt, nie erschienen — in der Liste als
// verbucht — in der Liste als „Ausgetreten" ausgewiesen und trotzdem unter // „Ausgetreten" ausgewiesen und trotzdem unter „Geplant" gefunden.
// „Geplant" gefunden. //
// Die zweite Haelfte muss ueber ausgetreten() laufen und nicht nur ueber
// `exit_date > asOf`. Ein Nichtantritt traegt als Austrittsdatum den
// Eintrittstag; liegt der in der Zukunft, ist auch der Austritt groesser
// als der Stichtag und rutschte durch eine Pruefung, die nur den Stichtag
// kennt, wieder hindurch. Genau das war nach der ersten Korrektur noch zu
// sehen: der Filter blieb wirkungslos, weil er den falschen Vergleich zog.
if (wanted.size === 1 && wanted.has("Geplant")) { if (wanted.size === 1 && wanted.has("Geplant")) {
return eb.and([ return eb.and([eb("entry_date", ">", asOf), eb.not(ausgetreten(eb, asOf))]);
eb("entry_date", ">", asOf),
eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)]),
]);
} }
if (wanted.size === 1 && wanted.has("Ausgetreten")) { if (wanted.size === 1 && wanted.has("Ausgetreten")) {
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]); return ausgetreten(eb, asOf);
} }
const wantsAktiv = wanted.has("Aktiv"); const wantsAktiv = wanted.has("Aktiv");

View File

@@ -150,13 +150,28 @@ export type OrgLookups = {
// „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der // „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der
// noch bevorsteht und nicht zurückgenommen wurde. // noch bevorsteht und nicht zurückgenommen wurde.
// //
// ── Warum ein Austritt am Eintrittstag *jeden* Stichtag schlägt ──────
//
// Die erste Fassung dieser Regel prüfte nur `exit_date <= asOf`, und das
// reichte für den Nichtantritt nicht: Migration 20260814100000 setzt bei
// „No Show" das Austrittsdatum **auf den Eintrittstag**. Liegt der noch in
// der Zukunft, ist auch der Austritt in der Zukunft — die Regel fiel durch
// auf „Geplant", und die Liste zeigte jemanden als anstehenden Eintritt, von
// dem längst feststand, dass er nicht kommt.
//
// Die Migration begründet ihr Vorgehen damit, „nie aktiv" folge aus dem
// Datum von selbst. In SQL stimmt das; hier stand der Satz nur als Absicht
// und nicht als Klausel. Er steht jetzt da: endet das Verhältnis nicht
// später, als es beginnt, gab es keinen einzigen Tag Beschäftigung — zu
// keinem Stichtag, auch zu keinem vor dem Eintritt.
//
// lib/employee-status-filter.ts bildet dieselbe Reihenfolge in SQL ab; die // lib/employee-status-filter.ts bildet dieselbe Reihenfolge in SQL ab; die
// beiden müssen Klausel für Klausel zusammenpassen. // beiden müssen Klausel für Klausel zusammenpassen.
export function deriveStatusAsOf( export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string asOf: string
): EmploymentStatus { ): EmploymentStatus {
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten"; if (e.exit_date && (e.exit_date <= asOf || e.exit_date <= e.entry_date)) return "Ausgetreten";
if (e.entry_date > asOf) return "Geplant"; if (e.entry_date > asOf) return "Geplant";
if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz"; if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz";
return "Aktiv"; return "Aktiv";

View File

@@ -119,8 +119,10 @@ const db = new Kysely<Schema>({
}, },
}); });
const STICHTAG = "2026-09-15";
const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") => const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") =>
sortiere(db.selectFrom("employees").select("id"), feld, richtung) sortiere(db.selectFrom("employees").select("id"), feld, richtung, STICHTAG)
.compile() .compile()
.sql.replace(/^.*?order by /s, "") .sql.replace(/^.*?order by /s, "")
.replace(/\s+/g, " "); .replace(/\s+/g, " ");
@@ -222,9 +224,46 @@ describe("das erzeugte SQL", () => {
it("setzt keinen Wert aus der Adresse in die Abfrage", () => { it("setzt keinen Wert aus der Adresse in die Abfrage", () => {
// Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus // Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus
// der Adresse hierher, stünden sie im SQL statt als Parameter. // der Adresse hierher, stünden sie im SQL statt als Parameter.
//
// Gebunden wird genau ein Wert, und er kommt nicht aus der Adresse: der
// Stichtag der Statusableitung. Deshalb wird er hier namentlich
// zugelassen und alles andere ausgeschlossen — „gar keine Parameter"
// wäre die schärfere Zusicherung, aber die falsche.
for (const f of SORTIERFELDER) { for (const f of SORTIERFELDER) {
const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc").compile(); const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc", STICHTAG).compile();
expect(parameters, f.value).toEqual([]); expect(new Set(parameters), f.value).toEqual(new Set(f.value === "status" ? [STICHTAG] : []));
} }
}); });
// ── Status: abgeleitet, nicht aus der Spalte gelesen ──────────────────
//
// Die Zeile zeigt den zum Stichtag abgeleiteten Status. Nach
// `employees.status` zu sortieren hiesse, nach einem Wert zu ordnen, der
// nirgends auf der Seite steht — eine als „Ausgetreten" beschriftete Person
// stünde mitten unter den aktiven.
describe("Status", () => {
it("ordnet nach der Ableitung und nicht nach der Spalte", () => {
const sql = ordnung("status", "asc");
expect(sql).toContain("exit_date");
expect(sql).toContain("entry_date");
expect(sql).toContain("karenz_start_date");
expect(sql).not.toContain('"status"');
});
it("hält die Reihenfolge des Dienstverhältnisses ein", () => {
// Aktiv (1), Karenz (2), Geplant (3), Ausgetreten (4) — dieselbe
// Folge wie im Aufzählungstyp, und aussagekräftiger als alphabetisch.
const sql = ordnung("status", "asc");
expect(sql.indexOf("then 4")).toBeLessThan(sql.indexOf("then 3"));
expect(sql.indexOf("then 3")).toBeLessThan(sql.indexOf("then 2"));
expect(sql).toContain("else 1");
});
it("erkennt den Nichtantritt wie die Ableitung", () => {
// exit_date <= entry_date: kein einziger Tag Beschäftigung. Fehlte der
// Vergleich, sortierte ein künftiger Nichtantritt unter „Geplant",
// während sein Chip „Ausgetreten" zeigt.
expect(ordnung("status", "asc")).toContain("exit_date <= entry_date");
});
});
}); });

View File

@@ -46,9 +46,16 @@ describe("derivedStatusFilter — Geplant", () => {
// Die eigentliche Zusicherung: ohne diese Hälfte war der Filter falsch. // Die eigentliche Zusicherung: ohne diese Hälfte war der Filter falsch.
it("schliesst aus, wessen Austritt schon vollzogen ist", () => { it("schliesst aus, wessen Austritt schon vollzogen ist", () => {
const sql = bedingung(["Geplant"]); expect(bedingung(["Geplant"])).toContain('"exit_date" <=');
expect(sql).toContain('"exit_date" is null'); });
expect(sql).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"');
}); });
}); });
@@ -58,6 +65,10 @@ describe("derivedStatusFilter — Ausgetreten", () => {
expect(sql).toContain('"exit_date" is not null'); expect(sql).toContain('"exit_date" is not null');
expect(sql).toContain('"exit_date" <='); 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", () => { describe("derivedStatusFilter — nicht abgedeckte Auswahl", () => {
@@ -90,6 +101,41 @@ describe("Filter und Anzeige meinen dasselbe", () => {
e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-12-31" }, e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-12-31" },
erwartet: "Geplant", 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) { for (const { was, e, erwartet } of faelle) {