import { describe, expect, it } from "vitest"; import { resolveOrgSnapshot } from "@/lib/orgchart-data"; // 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 Args = Parameters[0]; 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] { return { id, personnel_number: 1000, first_name: "Test", last_name: id, job_title: "Freitext auf der Person", karenz_start_date: null, karenz_return_date: null, absence_type: null, ...overrides, }; } function snapshot(args: Partial & { asOf: string }) { return resolveOrgSnapshot({ units: UNITS, positions: POSITIONS, assignments: [], employees: [], pending: [], historyStartsAt: null, ...args, }); } 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("ü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("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("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("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("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("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.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("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("ü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); }); }); 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); }); });