Files
alpenwerk-hr/lib/employee-status-filter.ts
Andrei Laas 1cbed1a8f5 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.
2026-09-15 22:16:30 +02:00

115 lines
4.8 KiB
TypeScript

import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
import type { Schema } from "./db/schema";
import type { EmploymentStatus } from "./types";
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
//
// The employee list is paged in the database, so it cannot derive status in
// JavaScript the way the reports and the dashboard do — it has to filter on
// the server. Filtering on the `employees.status` column instead was the
// reason a dashboard tile and the list it links to could disagree: that
// column holds whatever the last mutation or cron run wrote, while every
// other surface computes status from entry/exit/karenz dates.
//
// Kept deliberately close to deriveStatusAsOf, clause for clause:
//
// 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, 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">;
/**
* 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)])]);
}
/**
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
*
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
*/
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
const wanted = new Set(statuses);
if (wanted.size === 0) return null;
// „Geplant" ist ein Eintritt, der noch bevorsteht **und** nicht
// zurueckgenommen wurde. Ohne die zweite Haelfte zaehlte der Filter die
// 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.not(ausgetreten(eb, asOf))]);
}
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
return ausgetreten(eb, asOf);
}
const wantsAktiv = wanted.has("Aktiv");
const wantsKarenz = wanted.has("Karenz");
// Everyone employed today, whether or not they are on leave.
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
return eb.and([
employed(eb, asOf),
eb("karenz_start_date", "is not", null),
eb("karenz_start_date", "<=", asOf),
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
]);
}
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
// Employed but *not* inside a karenz window: either no start date, a
// start still ahead, or a return that has already happened.
return eb.and([
employed(eb, asOf),
eb.or([
eb("karenz_start_date", "is", null),
eb("karenz_start_date", ">", asOf),
eb("karenz_return_date", "<=", asOf),
]),
]);
}
// Mixed selections spanning employed and non-employed states have no UI
// path today; filtering on a guess would be worse than not filtering.
return null;
}