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; /** * 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 { 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 { 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 | 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; }