Ask for someone's organisation on a day they actually have one
An employee starting 01.09. showed "Keine Führungskraft (Geschäftsführung)" although their team has one — Josef Bauer, on chief position 60000752. The reporting line was requested as of today, and today that person holds no assignment, so om_reporting_lines() returned no row at all. The database function was right; the caller asked the wrong question. What made it look like a data problem rather than a date problem: the header did show the unit and the position, because pickPlacements() falls back to the next best assignment when none is current. Two notions of where someone sits — one forgiving, one strict — sitting next to each other on the same page. orgAsOf() pulls the date into the employment: the first day for someone not yet started, the last for someone who has left, today otherwise. Exit dates are exclusive throughout the model, so the last working day is the day before. Anyone already gone had the same defect for the same reason, which is why the rule covers both ends rather than special-casing the case that was reported. Verified against the live database: as of today no row, as of 2026-09-01 the manager is Josef Bauer. Six unit tests over the boundaries, checked by mutation — remove the future-entry branch and one fails. Open positions still resolve as of today: they belong to the organisation, not to the person whose file is open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { currentUserId } from "@/lib/auth/session";
|
|||||||
import { withUser } from "@/lib/db";
|
import { withUser } from "@/lib/db";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
import { loadPlacements, loadReportingLines, orgAsOf } from "@/lib/placement";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
@@ -14,15 +14,23 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
|||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
const data = await withUser(await currentUserId(), async (tx) => {
|
const data = await withUser(await currentUserId(), async (tx) => {
|
||||||
|
// Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich
|
||||||
|
// der Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat.
|
||||||
|
// Eine Person, die am 01.09. anfängt, hat heute keine laufende Besetzung
|
||||||
|
// — zu heute gefragt lieferte om_reporting_lines() nichts, und die Akte
|
||||||
|
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
|
||||||
|
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
|
||||||
|
if (!employee) return null;
|
||||||
|
|
||||||
|
const asOf = orgAsOf(employee, today);
|
||||||
|
|
||||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||||
// achthundert.
|
// achthundert.
|
||||||
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
const [ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = await Promise.all([
|
||||||
await Promise.all([
|
loadReportingLines(tx, asOf, { employeeId: id }),
|
||||||
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
loadReportingLines(tx, asOf, { actingManagerId: id }),
|
||||||
loadReportingLines(tx, today, { employeeId: id }),
|
|
||||||
loadReportingLines(tx, today, { actingManagerId: id }),
|
|
||||||
tx
|
tx
|
||||||
.selectFrom("employee_history")
|
.selectFrom("employee_history")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -33,11 +41,12 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
|||||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||||
loadOrgMaps(tx),
|
loadOrgMaps(tx),
|
||||||
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
loadPlacements(tx, { asOf, employeeIds: [id] }),
|
||||||
|
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||||
|
// Organisation, nicht zu dieser Person.
|
||||||
loadOpenPositions(tx),
|
loadOpenPositions(tx),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!employee) return null;
|
|
||||||
const line = ownLines[0] ?? null;
|
const line = ownLines[0] ?? null;
|
||||||
|
|
||||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import { sql, type Tx } from "./db";
|
import { sql, type Tx } from "./db";
|
||||||
|
import { addDaysIso } from "./format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird.
|
||||||
|
*
|
||||||
|
* „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat
|
||||||
|
* heute keine laufende Besetzung — om_reporting_lines() liefert dann gar
|
||||||
|
* keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team
|
||||||
|
* eine hat. Wer ausgetreten ist, ebenso.
|
||||||
|
*
|
||||||
|
* Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements()
|
||||||
|
* ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen
|
||||||
|
* davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der
|
||||||
|
* Akte standen sie nebeneinander.
|
||||||
|
*
|
||||||
|
* Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen.
|
||||||
|
* Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den
|
||||||
|
* letzten. Sonst bleibt es heute.
|
||||||
|
*/
|
||||||
|
export function orgAsOf(
|
||||||
|
employee: { entry_date: string; exit_date: string | null },
|
||||||
|
today: string
|
||||||
|
): string {
|
||||||
|
if (employee.entry_date > today) return employee.entry_date;
|
||||||
|
// exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag
|
||||||
|
// ist der Tag davor.
|
||||||
|
if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1);
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
|
||||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
||||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
||||||
|
|||||||
54
tests/unit/org-as-of.test.ts
Normal file
54
tests/unit/org-as-of.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { orgAsOf } from "@/lib/placement";
|
||||||
|
|
||||||
|
// orgAsOf() ist reine Rechnung, liegt aber neben den Ladefunktionen — und die
|
||||||
|
// ziehen über lib/db das Paket `server-only` herein, das ausserhalb von
|
||||||
|
// Next.js beim Import wirft.
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
// Der Stichtag, zu dem die Organisation einer Person betrachtet wird.
|
||||||
|
//
|
||||||
|
// „Heute" ist die naheliegende und für zwei Gruppen falsche Antwort. Wer
|
||||||
|
// später anfängt oder schon gegangen ist, hat heute keine laufende
|
||||||
|
// Besetzung — om_reporting_lines() liefert dann keine Zeile, und die Akte
|
||||||
|
// behauptet „Keine Führungskraft", obwohl das Team eine hat. Genau so ist es
|
||||||
|
// aufgefallen.
|
||||||
|
|
||||||
|
const HEUTE = "2026-08-10";
|
||||||
|
|
||||||
|
describe("Stichtag der Organisation", () => {
|
||||||
|
it("bleibt heute für eine laufende Beschäftigung", () => {
|
||||||
|
expect(orgAsOf({ entry_date: "2015-03-01", exit_date: null }, HEUTE)).toBe(HEUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt bei künftigem Eintritt den ersten Tag", () => {
|
||||||
|
// Sonst steht in der Akte einer Person, die am 01.09. anfängt, heute
|
||||||
|
// keine Führungskraft — obwohl die Planstelle einer Einheit mit Leitung
|
||||||
|
// zugeordnet ist.
|
||||||
|
expect(orgAsOf({ entry_date: "2026-09-01", exit_date: null }, HEUTE)).toBe("2026-09-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt bei Austritt den letzten Arbeitstag", () => {
|
||||||
|
// exit_date ist ausschliessend wie überall im Modell; am Austrittstag
|
||||||
|
// selbst besteht die Besetzung nicht mehr. Zu diesem Tag gefragt käme
|
||||||
|
// wieder nichts heraus.
|
||||||
|
expect(orgAsOf({ entry_date: "2015-03-01", exit_date: "2026-07-31" }, HEUTE)).toBe("2026-07-30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt heute, solange ein künftiger Austritt noch nicht wirksam ist", () => {
|
||||||
|
expect(orgAsOf({ entry_date: "2015-03-01", exit_date: "2026-12-31" }, HEUTE)).toBe(HEUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt am Eintrittstag selbst diesen Tag, nicht den Vortag", () => {
|
||||||
|
// Grenzfall: heute ist der erste Arbeitstag. Die Zuordnung gilt ab
|
||||||
|
// heute, also ist heute richtig.
|
||||||
|
expect(orgAsOf({ entry_date: HEUTE, exit_date: null }, HEUTE)).toBe(HEUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt Ein- und Austritt am selben Tag ohne Sprung ins Nichts", () => {
|
||||||
|
// Wer den Dienst nicht antritt: die Datenbank erlaubt exit = entry. Der
|
||||||
|
// Stichtag liegt dann vor dem Eintritt und liefert nichts — das ist
|
||||||
|
// richtig so, denn eine Organisation hatte diese Person nie.
|
||||||
|
expect(orgAsOf({ entry_date: "2026-07-31", exit_date: "2026-07-31" }, HEUTE)).toBe("2026-07-30");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user