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,27 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOrgSnapshot } from "@/lib/orgchart-data";
|
||||
|
||||
const DIV = "div-1";
|
||||
const TEAM_A = "team-a";
|
||||
const TEAM_B = "team-b";
|
||||
// Der Stand zu einem Stichtag. Im Altmodell mussten dafür drei Quellen
|
||||
// versöhnt werden; im OM-Modell beantwortet die zeitabhängige Besetzung fast
|
||||
// alles allein — was diese Datei deutlich kürzer macht, aber nicht
|
||||
// überflüssig: die Projektion vorgemerkter Versetzungen und die Frage, wer am
|
||||
// Stichtag überhaupt dazuzählte, entscheiden sich weiterhin hier.
|
||||
|
||||
type EmployeeInput = Parameters<typeof resolveOrgSnapshot>[0]["employees"][number];
|
||||
type AssignmentInput = Parameters<typeof resolveOrgSnapshot>[0]["assignments"][number];
|
||||
type Args = Parameters<typeof resolveOrgSnapshot>[0];
|
||||
|
||||
function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput {
|
||||
const UNITS: Args["units"] = [
|
||||
{ id: "gmbh", parentId: null },
|
||||
{ id: "prod", parentId: "gmbh" },
|
||||
{ id: "team-a", parentId: "prod" },
|
||||
{ id: "team-b", parentId: "prod" },
|
||||
];
|
||||
|
||||
// Planstellen: je Einheit eine Leitung, dazu Mitarbeiterstellen.
|
||||
const POSITIONS: Args["positions"] = [
|
||||
{ id: "p-gf", position_number: "60000001", org_unit_id: "gmbh", is_chief: true, jobs: { title: "Geschäftsführung" } },
|
||||
{ id: "p-bl", position_number: "60000002", org_unit_id: "prod", is_chief: true, jobs: { title: "Bereichsleitung" } },
|
||||
{ id: "p-tl-a", position_number: "60000003", org_unit_id: "team-a", is_chief: true, jobs: { title: "Teamleitung A" } },
|
||||
{ id: "p-tl-b", position_number: "60000004", org_unit_id: "team-b", is_chief: true, jobs: { title: "Teamleitung B" } },
|
||||
{ id: "p-a1", position_number: "60000005", org_unit_id: "team-a", is_chief: false, jobs: { title: "Monteur:in" } },
|
||||
{ id: "p-b1", position_number: "60000006", org_unit_id: "team-b", is_chief: false, jobs: { title: "Fräser:in" } },
|
||||
];
|
||||
|
||||
function emp(id: string, overrides: Partial<Args["employees"][number]> = {}): Args["employees"][number] {
|
||||
return {
|
||||
id,
|
||||
personnel_number: 1000,
|
||||
first_name: "Test",
|
||||
last_name: id,
|
||||
job_title: "Mitarbeiter:in",
|
||||
manager_id: null,
|
||||
team_id: TEAM_A,
|
||||
division_id: DIV,
|
||||
is_lead: false,
|
||||
org_level: 3,
|
||||
entry_date: "2020-01-01",
|
||||
exit_date: null,
|
||||
job_title: "Freitext auf der Person",
|
||||
karenz_start_date: null,
|
||||
karenz_return_date: null,
|
||||
absence_type: null,
|
||||
@@ -29,141 +40,158 @@ function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput
|
||||
};
|
||||
}
|
||||
|
||||
function assignment(employeeId: string, overrides: Partial<AssignmentInput> = {}): AssignmentInput {
|
||||
return {
|
||||
employee_id: employeeId,
|
||||
manager_id: null,
|
||||
team_id: TEAM_A,
|
||||
division_id: DIV,
|
||||
job_title: "Mitarbeiter:in",
|
||||
is_lead: false,
|
||||
org_level: 3,
|
||||
valid_from: "2020-01-01",
|
||||
...overrides,
|
||||
};
|
||||
function snapshot(args: Partial<Args> & { asOf: string }) {
|
||||
return resolveOrgSnapshot({
|
||||
units: UNITS,
|
||||
positions: POSITIONS,
|
||||
assignments: [],
|
||||
employees: [],
|
||||
pending: [],
|
||||
historyStartsAt: null,
|
||||
...args,
|
||||
});
|
||||
}
|
||||
|
||||
const TEAMS = [
|
||||
{ id: TEAM_A, department_id: "dept-1" },
|
||||
{ id: TEAM_B, department_id: "dept-2" },
|
||||
];
|
||||
const DEPARTMENTS = [
|
||||
{ id: "dept-1", division_id: DIV },
|
||||
{ id: "dept-2", division_id: "div-2" },
|
||||
];
|
||||
|
||||
function snapshot(args: Partial<Parameters<typeof resolveOrgSnapshot>[0]> & { asOf: string }) {
|
||||
return resolveOrgSnapshot({ employees: [], assignments: [], teams: TEAMS, departments: DEPARTMENTS, pending: [], ...args });
|
||||
}
|
||||
|
||||
describe("membership as of a date", () => {
|
||||
it("excludes someone who had not started yet and includes them once they have", () => {
|
||||
const employees = [emp("a", { entry_date: "2026-06-01" })];
|
||||
expect(snapshot({ asOf: "2026-05-31", employees }).employees).toHaveLength(0);
|
||||
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
|
||||
describe("Zugehörigkeit zum Stichtag", () => {
|
||||
it("zeigt nur, wer am Stichtag eine Planstelle innehatte", () => {
|
||||
// Die Zugehörigkeit ist keine eigene Regel mehr: wer keine Planstelle
|
||||
// hat, steht nicht in der Organisation. Ein-, Austritt und geplanter
|
||||
// Eintritt stecken alle in der Gültigkeit der Besetzung.
|
||||
const employees = [emp("a"), emp("b")];
|
||||
const result = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees,
|
||||
assignments: [{ employee_id: "a", position_id: "p-a1" }],
|
||||
});
|
||||
expect(result.employees.map((e) => e.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("excludes someone from their exit date onwards", () => {
|
||||
const employees = [emp("a", { exit_date: "2026-06-30" })];
|
||||
expect(snapshot({ asOf: "2026-06-29", employees }).employees).toHaveLength(1);
|
||||
expect(snapshot({ asOf: "2026-06-30", employees }).employees).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps someone on Karenz in the chart", () => {
|
||||
const employees = [emp("a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" })];
|
||||
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
|
||||
it("übergeht eine Besetzung auf einer am Stichtag ungültigen Planstelle", () => {
|
||||
// loadOrgAsOf filtert Planstellen bereits nach Gültigkeit; kommt eine
|
||||
// Besetzung ohne passende Planstelle an, wäre sie im Baum nicht
|
||||
// verortbar.
|
||||
const result = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees: [emp("a")],
|
||||
assignments: [{ employee_id: "a", position_id: "gibtesnicht" }],
|
||||
});
|
||||
expect(result.employees).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placement as of a date", () => {
|
||||
it("uses the assignment interval covering the date, not today's row on employees", () => {
|
||||
const employees = [emp("a", { team_id: TEAM_B, job_title: "Heutiger Titel" })];
|
||||
const assignments = [assignment("a", { team_id: TEAM_A, job_title: "Damaliger Titel" })];
|
||||
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments }).employees;
|
||||
expect(result.team_id).toBe(TEAM_A);
|
||||
expect(result.job_title).toBe("Damaliger Titel");
|
||||
describe("Berichtslinie", () => {
|
||||
const employees = [emp("gf"), emp("bl"), emp("tl-a"), emp("a1")];
|
||||
const assignments = [
|
||||
{ employee_id: "gf", position_id: "p-gf" },
|
||||
{ employee_id: "bl", position_id: "p-bl" },
|
||||
{ employee_id: "tl-a", position_id: "p-tl-a" },
|
||||
{ employee_id: "a1", position_id: "p-a1" },
|
||||
];
|
||||
|
||||
it("führt eine Mitarbeiterin an die Leitung ihrer Einheit", () => {
|
||||
const result = snapshot({ asOf: "2026-06-01", employees, assignments });
|
||||
expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("tl-a");
|
||||
});
|
||||
|
||||
it("falls back to the employee row when no assignment covers the date", () => {
|
||||
const employees = [emp("a", { team_id: TEAM_B })];
|
||||
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments: [] }).employees;
|
||||
expect(result.team_id).toBe(TEAM_B);
|
||||
it("führt eine Leitung an die Leitung darüber, nicht an sich selbst", () => {
|
||||
const result = snapshot({ asOf: "2026-06-01", employees, assignments });
|
||||
expect(result.employees.find((e) => e.id === "tl-a")!.manager_id).toBe("bl");
|
||||
expect(result.employees.find((e) => e.id === "gf")!.manager_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rollt bei unbesetzter Leitung eine Ebene hoch", () => {
|
||||
const ohneTeamleitung = assignments.filter((a) => a.employee_id !== "tl-a");
|
||||
const result = snapshot({ asOf: "2026-06-01", employees, assignments: ohneTeamleitung });
|
||||
expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("bl");
|
||||
});
|
||||
|
||||
it("nennt bei Abwesenheit beide: die zuständige und die tatsächliche Leitung", () => {
|
||||
// Sonst gäbe die Oberfläche die Vertretung stillschweigend als die echte
|
||||
// Führungskraft aus.
|
||||
const abwesend = employees.map((e) =>
|
||||
e.id === "tl-a" ? emp("tl-a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" }) : e
|
||||
);
|
||||
const [a1] = snapshot({ asOf: "2026-06-01", employees: abwesend, assignments }).employees.filter((e) => e.id === "a1");
|
||||
expect(a1.manager_id).toBe("bl");
|
||||
expect(a1.formal_manager_id).toBe("tl-a");
|
||||
});
|
||||
|
||||
it("lässt formal_manager_id leer, solange niemand vertritt", () => {
|
||||
const [a1] = snapshot({ asOf: "2026-06-01", employees, assignments }).employees.filter((e) => e.id === "a1");
|
||||
expect(a1.formal_manager_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("orphan re-rooting", () => {
|
||||
// Without this the whole reporting line below an absent manager silently
|
||||
// disappears from the chart instead of moving up a level.
|
||||
it("drops a manager reference to somebody not employed on that date", () => {
|
||||
const employees = [
|
||||
emp("boss", { exit_date: "2026-01-01", org_level: 2, is_lead: true }),
|
||||
emp("report", { manager_id: "boss" }),
|
||||
];
|
||||
const assignments = [assignment("report", { manager_id: "boss" })];
|
||||
const result = snapshot({ asOf: "2026-06-01", employees, assignments }).employees;
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("report");
|
||||
expect(result[0].manager_id).toBeNull();
|
||||
describe("Tätigkeit", () => {
|
||||
it("nimmt die Tätigkeit der Planstelle, nicht das Freitextfeld der Person", () => {
|
||||
// Bei einer projizierten Versetzung ist nur die erste schon richtig.
|
||||
const [result] = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees: [emp("a", { job_title: "Veraltet" })],
|
||||
assignments: [{ employee_id: "a", position_id: "p-a1" }],
|
||||
}).employees;
|
||||
expect(result.job_title).toBe("Monteur:in");
|
||||
expect(result.position_number).toBe("60000005");
|
||||
});
|
||||
});
|
||||
|
||||
describe("future projection from pending changes", () => {
|
||||
const leadB = emp("lead-b", { id: "lead-b", team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 });
|
||||
describe("Projektion vorgemerkter Versetzungen", () => {
|
||||
const employees = [emp("bl"), emp("tl-b"), emp("a")];
|
||||
const assignments = [
|
||||
{ employee_id: "bl", position_id: "p-bl" },
|
||||
{ employee_id: "tl-b", position_id: "p-tl-b" },
|
||||
{ employee_id: "a", position_id: "p-a1" },
|
||||
];
|
||||
|
||||
it("moves an employee into the target team and under that team's lead", () => {
|
||||
const employees = [emp("a", { manager_id: "lead-a" }), leadB];
|
||||
const assignments = [assignment("a", { manager_id: "lead-a" }), assignment("lead-b", { team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 })];
|
||||
const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }];
|
||||
|
||||
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending });
|
||||
it("setzt die Person auf die Zielplanstelle und damit unter deren Leitung", () => {
|
||||
const result = snapshot({
|
||||
asOf: "2026-09-01",
|
||||
employees,
|
||||
assignments,
|
||||
pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } }],
|
||||
});
|
||||
const moved = result.employees.find((e) => e.id === "a")!;
|
||||
expect(moved.team_id).toBe(TEAM_B);
|
||||
expect(moved.division_id).toBe("div-2");
|
||||
expect(moved.manager_id).toBe("lead-b");
|
||||
expect(moved.org_unit_id).toBe("team-b");
|
||||
expect(moved.job_title).toBe("Fräser:in");
|
||||
expect(moved.manager_id).toBe("tl-b");
|
||||
expect(result.projectedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("lets a later change win over an earlier one", () => {
|
||||
const employees = [emp("a")];
|
||||
const assignments = [assignment("a")];
|
||||
const pending = [
|
||||
{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B, new_title: "Zwischenstand" } },
|
||||
{ employee_id: "a", effective_date: "2026-09-01", payload: { new_team_id: TEAM_A, new_title: "Endstand" } },
|
||||
];
|
||||
const [result] = snapshot({ asOf: "2026-10-01", employees, assignments, pending }).employees;
|
||||
expect(result.team_id).toBe(TEAM_A);
|
||||
expect(result.job_title).toBe("Endstand");
|
||||
it("lässt eine spätere Versetzung über eine frühere gewinnen", () => {
|
||||
const [result] = snapshot({
|
||||
asOf: "2026-10-01",
|
||||
employees,
|
||||
assignments,
|
||||
pending: [
|
||||
{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } },
|
||||
{ employee_id: "a", effective_date: "2026-09-01", payload: { target_position_id: "p-a1" } },
|
||||
],
|
||||
}).employees.filter((e) => e.id === "a");
|
||||
expect(result.org_unit_id).toBe("team-a");
|
||||
});
|
||||
|
||||
it("leaves an untouched employee's manager exactly as recorded", () => {
|
||||
// Deliberately deviating from the resolve rule: real data drifts, and a
|
||||
// snapshot must not silently "repair" reporting lines it was not asked
|
||||
// to change.
|
||||
const employees = [emp("a", { manager_id: "someone-else" }), emp("someone-else", { id: "someone-else" }), leadB];
|
||||
const assignments = [assignment("a", { manager_id: "someone-else" })];
|
||||
const [result] = snapshot({ asOf: "2026-09-01", employees, assignments }).employees;
|
||||
expect(result.manager_id).toBe("someone-else");
|
||||
});
|
||||
|
||||
it("ignores pending changes for a date the caller did not ask about", () => {
|
||||
// loadOrgAsOf only fetches pending rows for a future date, so an empty
|
||||
// list here must simply mean "no projection", not "drop the employee".
|
||||
const employees = [emp("a")];
|
||||
const assignments = [assignment("a")];
|
||||
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending: [] });
|
||||
it("übergeht eine Versetzung auf eine unbekannte Planstelle, statt die Person zu verlieren", () => {
|
||||
const result = snapshot({
|
||||
asOf: "2026-09-01",
|
||||
employees,
|
||||
assignments,
|
||||
pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "weg" } }],
|
||||
});
|
||||
expect(result.employees.find((e) => e.id === "a")!.org_unit_id).toBe("team-a");
|
||||
expect(result.projectedCount).toBe(0);
|
||||
expect(result.employees).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("historyStartsAt", () => {
|
||||
it("reports the earliest recorded assignment so the UI can flag older dates", () => {
|
||||
const employees = [emp("a"), emp("b", { id: "b" })];
|
||||
const assignments = [assignment("a", { valid_from: "2023-05-01" }), assignment("b", { valid_from: "2021-02-01" })];
|
||||
expect(snapshot({ asOf: "2026-01-01", employees, assignments }).historyStartsAt).toBe("2021-02-01");
|
||||
});
|
||||
|
||||
it("is null when nothing is recorded yet", () => {
|
||||
expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull();
|
||||
describe("Vakanzen", () => {
|
||||
it("meldet jede am Stichtag unbesetzte Planstelle", () => {
|
||||
// Vakanz ist im OM-Modell kein eigenes Objekt, sondern das Komplement der
|
||||
// Besetzungen — sie kann deshalb gar nicht mehr aus dem Tritt geraten.
|
||||
const result = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees: [emp("a")],
|
||||
assignments: [{ employee_id: "a", position_id: "p-a1" }],
|
||||
});
|
||||
expect(result.vacancies.map((v) => v.position_id).sort()).toEqual(["p-b1", "p-bl", "p-gf", "p-tl-a", "p-tl-b"]);
|
||||
expect(result.vacancies.find((v) => v.position_id === "p-tl-a")!.is_chief).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user