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

@@ -13,21 +13,40 @@ import type { EmploymentStatus } from "./types";
//
// Kept deliberately close to deriveStatusAsOf, clause for clause:
//
// exit_date <= asOf -> Ausgetreten
// entry_date > asOf -> Geplant
// karenz window covers asOf -> Karenz
// otherwise -> Aktiv
// exit_date <= asOf ODER exit_date <= entry_date -> Ausgetreten
// entry_date > asOf -> Geplant
// karenz window covers asOf -> Karenz
// otherwise -> Aktiv
//
// Die Reihenfolge der ersten beiden ist nicht beliebig: ein abgeschlossener
// 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
// against a real database, which is the only place that can prove it.
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> {
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
// zurueckgenommen wurde. Ohne die zweite Haelfte zaehlte der Filter die
// No-Shows mit: eingestellt, nie erschienen, Austritt vor dem Eintrittstag
// verbucht — in der Liste als „Ausgetreten" ausgewiesen und trotzdem unter
// „Geplant" gefunden.
// Nichtantritte mit: eingestellt, nie erschienen — in der Liste als
// „Ausgetreten" ausgewiesen und trotzdem unter „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")) {
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.not(ausgetreten(eb, asOf))]);
}
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");