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:
@@ -1,4 +1,3 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
@@ -9,148 +8,202 @@ import {
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededLocation,
|
||||
pickSeededTeam,
|
||||
pickSeededUnit,
|
||||
signInAs,
|
||||
teamLeadId,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Position validity window + delete (supabase/migrations/20260716120000_position_validity_and_delete.sql):
|
||||
// positions now carry a required valid_from ("gültig ab") date, an open
|
||||
// position can be deleted again, and neither internal staffing nor an
|
||||
// external hire may assign an employee to a position before that date.
|
||||
describe("position validity and delete", () => {
|
||||
// Planstellenpflege im OM-Modell
|
||||
// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql).
|
||||
//
|
||||
// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit
|
||||
// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der
|
||||
// Ausschreibung hing — vorgesetzte Person, Team, is_lead — ergibt sich jetzt
|
||||
// aus der Einheit und wird deshalb hier nicht mehr geprüft: es kann gar nicht
|
||||
// mehr abweichen.
|
||||
describe("Planstellen anlegen und schliessen", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let superiorId: string;
|
||||
let unit: { id: string };
|
||||
const positionIds: string[] = [];
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
superiorId = (await teamLeadId(teamA.id))!;
|
||||
unit = await pickSeededUnit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Positions first: reports_to_employee_id / filled_by_employee_id
|
||||
// reference employees(id) with no cascade.
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
// Personen zuerst: die Besetzung hängt mit on delete cascade an der
|
||||
// Planstelle, die Person selbst nicht.
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
it("create_position records the given valid_from", async () => {
|
||||
it("übernimmt das angegebene Gültig-ab", async () => {
|
||||
const validFrom = isoDateOffset(10);
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom });
|
||||
positionIds.push(positionId);
|
||||
|
||||
const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
|
||||
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single();
|
||||
expect(data?.valid_from).toBe(validFrom);
|
||||
});
|
||||
|
||||
it("create_position defaults valid_from to today when omitted", async () => {
|
||||
const positionId = await createTestPosition(hrClient, superiorId);
|
||||
it("setzt Gültig-ab ohne Angabe auf heute", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id);
|
||||
positionIds.push(positionId);
|
||||
|
||||
const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
|
||||
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single();
|
||||
expect(data?.valid_from).toBe(isoDateOffset(0));
|
||||
});
|
||||
|
||||
it("delete_position removes an open position", async () => {
|
||||
const positionId = await createTestPosition(hrClient, superiorId);
|
||||
it("hängt die Planstelle an die angegebene Einheit", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id);
|
||||
positionIds.push(positionId);
|
||||
|
||||
const { data } = await adminClient.from("om_positions").select("org_unit_id, is_chief").eq("id", positionId).single();
|
||||
expect(data?.org_unit_id).toBe(unit.id);
|
||||
expect(data?.is_chief).toBe(false);
|
||||
});
|
||||
|
||||
it("teilt sich denselben Job-Katalogeintrag, statt ihn zu verdoppeln", async () => {
|
||||
// Sonst stünden „Schlosser:in" und „Schlosser" nebeneinander und jede
|
||||
// Auswertung nach Tätigkeit wäre wertlos.
|
||||
const title = `Geteilte Tätigkeit ${Date.now()}`;
|
||||
const first = await createTestPosition(hrClient, unit.id, { job_title: title });
|
||||
const second = await createTestPosition(hrClient, unit.id, { job_title: title });
|
||||
positionIds.push(first, second);
|
||||
|
||||
const { data } = await adminClient.from("om_positions").select("job_id").in("id", [first, second]);
|
||||
expect(new Set((data ?? []).map((p) => p.job_id)).size).toBe(1);
|
||||
});
|
||||
|
||||
it("weist eine Planstelle ohne Tätigkeit zurück", async () => {
|
||||
const { error } = await hrClient.rpc("create_position", {
|
||||
payload: { org_unit_id: unit.id, job_title: " " },
|
||||
});
|
||||
expect(error?.message).toMatch(/Tätigkeit/);
|
||||
});
|
||||
|
||||
it("lässt keine zweite Leitungsplanstelle für dieselbe Einheit zu", async () => {
|
||||
// Der Unique-Index erzwingt das ohnehin; die RPC soll es mit einer
|
||||
// Meldung abfangen, die in der Oberfläche etwas erklärt.
|
||||
const { data: existing } = await adminClient
|
||||
.from("om_positions")
|
||||
.select("org_unit_id")
|
||||
.eq("is_chief", true)
|
||||
.is("valid_to", null)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
const { error } = await hrClient.rpc("create_position", {
|
||||
payload: { org_unit_id: existing!.org_unit_id, job_title: "Zweite Leitung", is_chief: true },
|
||||
});
|
||||
expect(error?.message).toMatch(/Leitungsplanstelle/);
|
||||
});
|
||||
|
||||
it("löscht eine nie besetzte Planstelle vollständig", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id);
|
||||
|
||||
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await adminClient.from("positions").select("id").eq("id", positionId).maybeSingle();
|
||||
const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle();
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("delete_position rejects a filled position", async () => {
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-10) });
|
||||
it("weigert sich, eine besetzte Planstelle zu entfernen", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id);
|
||||
positionIds.push(positionId);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error: staffError } = await hrClient.rpc("staff_position_internally", {
|
||||
payload: { position_id: positionId, employee_id: employeeId },
|
||||
});
|
||||
expect(staffError).toBeNull();
|
||||
employeeIds.push(await hireTestEmployee(hrClient, positionId));
|
||||
|
||||
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
|
||||
expect(error?.message).toMatch(/Nur offene Positionen können gelöscht werden/);
|
||||
expect(error?.message).toMatch(/besetzt/);
|
||||
});
|
||||
|
||||
it("staff_position_internally rejects assigning to a position before its valid_from", async () => {
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(10) });
|
||||
it("schliesst eine früher besetzte Planstelle, statt die Historie zu löschen", async () => {
|
||||
// Sonst verschwände mit der Planstelle die Besetzungshistorie, und in der
|
||||
// Personalakte klaffte eine Lücke.
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
const employeeId = await hireTestEmployee(hrClient, positionId);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await hrClient.rpc("staff_position_internally", {
|
||||
payload: { position_id: positionId, employee_id: employeeId },
|
||||
await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
|
||||
});
|
||||
expect(error?.message).toMatch(/erst ab .* gültig/);
|
||||
});
|
||||
|
||||
it("staff_position_internally accepts assigning to a position on/after its valid_from", async () => {
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-1) });
|
||||
positionIds.push(positionId);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await hrClient.rpc("staff_position_internally", {
|
||||
payload: { position_id: positionId, employee_id: employeeId },
|
||||
});
|
||||
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it("hire_employee rejects an entry_date before the position's valid_from", async () => {
|
||||
const validFrom = isoDateOffset(10);
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
|
||||
positionIds.push(positionId);
|
||||
const location = await pickSeededLocation();
|
||||
const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle();
|
||||
expect(data?.valid_to).toBe(isoDateOffset(0));
|
||||
|
||||
const { error } = await hrClient.rpc("hire_employee", {
|
||||
payload: {
|
||||
first_name: "Integrationstest",
|
||||
last_name: `Person-${randomUUID().slice(0, 8)}`,
|
||||
gender: "w",
|
||||
birth_date: "1990-01-01",
|
||||
location_id: location.id,
|
||||
position_id: positionId,
|
||||
entry_date: isoDateOffset(5),
|
||||
source: "Extern",
|
||||
},
|
||||
});
|
||||
expect(error?.message).toMatch(/Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn/);
|
||||
});
|
||||
|
||||
it("hire_employee accepts an entry_date on/after the position's valid_from", async () => {
|
||||
const validFrom = isoDateOffset(10);
|
||||
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
|
||||
positionIds.push(positionId);
|
||||
const location = await pickSeededLocation();
|
||||
|
||||
const { data, error } = await hrClient.rpc("hire_employee", {
|
||||
payload: {
|
||||
first_name: "Integrationstest",
|
||||
last_name: `Person-${randomUUID().slice(0, 8)}`,
|
||||
gender: "w",
|
||||
birth_date: "1990-01-01",
|
||||
location_id: location.id,
|
||||
position_id: positionId,
|
||||
entry_date: validFrom,
|
||||
source: "Extern",
|
||||
},
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
if (data) employeeIds.push(data);
|
||||
const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId);
|
||||
expect(history?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Besetzung", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let unit: { id: string };
|
||||
const positionIds: string[] = [];
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
unit = await pickSeededUnit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
employeeIds.push(await hireTestEmployee(hrClient, positionId));
|
||||
|
||||
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/);
|
||||
});
|
||||
|
||||
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
|
||||
// Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten
|
||||
// Planstelle und Person unterschiedliche Tätigkeiten führen.
|
||||
const title = `Tätigkeit aus dem Katalog ${Date.now()}`;
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { job_title: title, valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
const employeeId = await hireTestEmployee(hrClient, positionId);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
|
||||
expect(data?.job_title).toBe(title);
|
||||
});
|
||||
|
||||
it("beendet die Besetzung beim Austritt und macht die Planstelle frei", async () => {
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
const employeeId = await hireTestEmployee(hrClient, positionId);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
|
||||
});
|
||||
|
||||
const { data } = await adminClient
|
||||
.from("position_assignments")
|
||||
.select("valid_to")
|
||||
.eq("position_id", positionId)
|
||||
.eq("employee_id", employeeId)
|
||||
.single();
|
||||
expect(data?.valid_to).toBe(isoDateOffset(-1));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user