Put the whole application on the OM model, and delete what it replaced

Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.

Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.

Was sich dadurch von selbst erledigt hat:

  - Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
    Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
    beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
    für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
    war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
  - Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
    rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
    vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
  - Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
    ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
  - Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
    plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
    keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
  - Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
    Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
    beantwortet werden.

Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.

Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.

Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.

Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
This commit is contained in:
2026-07-27 20:02:26 +02:00
parent 4929252f45
commit 27669e0359
41 changed files with 2203 additions and 2100 deletions

View File

@@ -0,0 +1,216 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
// Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in
// eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die
// Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit
// gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten
// müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine
// laufende Besetzung.
//
// Die Tests treiben die echten RPCs, nicht Inserts.
describe("position_assignments als Besetzungshistorie", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unitA: { id: string };
let unitB: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit();
unitB = await pickSeededUnit(unitA.id);
});
afterAll(async () => {
// Planstellen vor den Personen: die Besetzungen hängen an beiden.
for (const id of positionIds) await deleteTestPosition(id);
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshPosition(orgUnitId: string): Promise<string> {
const id = await createTestPosition(hrClient, orgUnitId);
positionIds.push(id);
return id;
}
async function freshEmployee(positionId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, positionId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("position_assignments")
.select("position_id, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("öffnet eine Besetzung bei der Einstellung", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(positionId);
expect(rows[0].valid_to).toBeNull();
});
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
// Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die
// Tätigkeit auf der Person und die der Planstelle auseinanderlaufen.
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { data: position } = await adminClient
.from("om_positions")
.select("jobs!inner(title)")
.eq("id", positionId)
.single();
const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title);
});
it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => {
// Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit
// einer Meldung tun, die in der Oberfläche etwas erklärt.
const positionId = await freshPosition(unitA.id);
await freshEmployee(positionId);
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i);
});
it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].position_id).toBe(from);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].position_id).toBe(to);
expect(rows[1].valid_to).toBeNull();
// Die Intervalle müssen exakt aneinanderstossen, sonst landet eine
// Stichtagsabfrage in einer Lücke.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("hält höchstens eine laufende Besetzung je Person", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => {
const besetzt = await freshPosition(unitA.id);
await freshEmployee(besetzt);
const andere = await freshPosition(unitB.id);
const employeeId = await freshEmployee(andere);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt },
});
expect(error?.message).toMatch(/bereits besetzt/i);
});
it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(from);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("change_type, effective_date, payload, status")
.eq("employee_id", employeeId);
expect(pending).toHaveLength(1);
expect(pending?.[0].status).toBe("pending");
expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to);
});
it("gibt die Planstelle beim Austritt frei", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { error } = await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0);
expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0));
});
it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => {
// Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie
// könnte sich unbemerkt überschneiden.
const positionId = await freshPosition(unitA.id);
const ersteR = await freshEmployee(positionId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
await freshEmployee(positionId, );
const { data } = await adminClient
.from("position_assignments")
.select("valid_from, valid_to")
.eq("position_id", positionId)
.order("valid_from");
const rows = data ?? [];
for (let i = 1; i < rows.length; i++) {
const vorher = rows[i - 1];
expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true);
}
});
it("ist ohne aktive HR-Sitzung nicht lesbar", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("position_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});