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,140 +0,0 @@
|
||||
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,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
|
||||
// Org-assignment history (supabase/migrations/20260724120000_employee_
|
||||
// assignment_history.sql). The point of capturing this with a trigger rather
|
||||
// than inside each RPC is that it holds for *every* write path — so these
|
||||
// tests drive the real RPCs and assert on the timeline they leave behind.
|
||||
describe("employee_assignments history", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function assignmentsFor(employeeId: string) {
|
||||
const { data } = await adminClient
|
||||
.from("employee_assignments")
|
||||
.select("team_id, job_title, valid_from, valid_to")
|
||||
.eq("employee_id", employeeId)
|
||||
.order("valid_from");
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
it("opens an interval when an employee is hired", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the old interval and opens a new one on transfer", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBe(isoDateOffset(0));
|
||||
expect(rows[1].team_id).toBe(teamB.id);
|
||||
expect(rows[1].valid_to).toBeNull();
|
||||
// Intervals must abut exactly, or an as-of query lands in a gap.
|
||||
expect(rows[1].valid_from).toBe(rows[0].valid_to);
|
||||
});
|
||||
|
||||
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
|
||||
});
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
expect(rows.at(-1)?.team_id).toBe(teamA.id);
|
||||
});
|
||||
|
||||
it("records a promotion's new title as its own interval", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("promote_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
|
||||
expect(rows.at(-1)?.valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("writes no new interval when nothing about the placement changed", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const before = await assignmentsFor(employeeId);
|
||||
|
||||
const { error } = await hrClient.rpc("change_employee_data", {
|
||||
payload: {
|
||||
employee_id: employeeId,
|
||||
effective_date: isoDateOffset(0),
|
||||
person: { phone: "+43 1 2345678" },
|
||||
contract: {},
|
||||
role: {},
|
||||
},
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
|
||||
});
|
||||
|
||||
it("keeps exactly one open interval per employee", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is not readable without an active HR session", async () => {
|
||||
const outsider = await createHrUser({ active: false });
|
||||
const outsiderClient = await signInAs(outsider);
|
||||
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
|
||||
expect(data ?? []).toHaveLength(0);
|
||||
await deleteTestUser(outsider);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,9 @@ describe("HR-only access (is_hr_user gate)", () => {
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
|
||||
const { error } = await client
|
||||
.from("org_units")
|
||||
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
createTestPosition,
|
||||
deleteTestEmployee,
|
||||
deleteTestPosition,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
pickSeededUnit,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
@@ -20,22 +22,32 @@ import type { Database } from "@/lib/supabase/types";
|
||||
describe("data integrity guards", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let unitA: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
const positionIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
unitA = await pickSeededUnit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
// Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine
|
||||
// geteilte wäre nach der ersten besetzt.
|
||||
async function freshPosition(): Promise<string> {
|
||||
const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition());
|
||||
employeeIds.push(employeeId);
|
||||
const karenzStartDate = isoDateOffset(-5);
|
||||
const { error } = await hrClient.rpc("start_karenz", {
|
||||
@@ -95,7 +107,7 @@ describe("data integrity guards", () => {
|
||||
});
|
||||
|
||||
it("rejects an employee_history row dated before the employee's entry_date", async () => {
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) });
|
||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) });
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await adminClient.from("employee_history").insert({
|
||||
@@ -109,7 +121,7 @@ describe("data integrity guards", () => {
|
||||
|
||||
it("accepts an employee_history row dated exactly on the entry_date", async () => {
|
||||
const entryDate = isoDateOffset(-10);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate });
|
||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate });
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await adminClient.from("employee_history").insert({
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
chiefOfUnit,
|
||||
createHrUser,
|
||||
createTestPosition,
|
||||
deleteTestEmployee,
|
||||
deleteTestPosition,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
pickSeededUnit,
|
||||
signInAs,
|
||||
teamLeadId,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
@@ -16,60 +18,89 @@ import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
|
||||
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
|
||||
// pending_org_changes row instead of writing to `employees` immediately;
|
||||
// pending_org_changes row instead of writing immediately;
|
||||
// apply_due_pending_changes() applies it once due.
|
||||
//
|
||||
// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und
|
||||
// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die
|
||||
// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr
|
||||
// employees.team_id/manager_id, die es nicht mehr gibt.
|
||||
describe("effective-dated mutations", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
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);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
unitA = await pickSeededUnit();
|
||||
unitB = await pickSeededUnit(unitA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
/** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */
|
||||
async function freshPosition(unitId: string): Promise<string> {
|
||||
const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
return positionId;
|
||||
}
|
||||
|
||||
async function freshEmployee(unitId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, await freshPosition(unitId));
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function lineOf(employeeId: string) {
|
||||
const { data } = await adminClient
|
||||
.rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) })
|
||||
.eq("employee_id", employeeId)
|
||||
.single();
|
||||
return data as unknown as { org_unit_id: string; formal_manager_id: string | null };
|
||||
}
|
||||
|
||||
it("transfer_employee with today's date writes immediately", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const newLead = await teamLeadId(teamB.id);
|
||||
const employeeId = await freshEmployee(unitA.id);
|
||||
const target = await freshPosition(unitB.id);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(employee?.team_id).toBe(teamB.id);
|
||||
expect(employee?.manager_id).toBe(newLead);
|
||||
const { data: assignment } = await adminClient
|
||||
.from("position_assignments")
|
||||
.select("position_id")
|
||||
.eq("employee_id", employeeId)
|
||||
.is("valid_to", null)
|
||||
.single();
|
||||
expect(assignment?.position_id).toBe(target);
|
||||
|
||||
const line = await lineOf(employeeId);
|
||||
expect(line.org_unit_id).toBe(unitB.id);
|
||||
expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id));
|
||||
});
|
||||
|
||||
it("transfer_employee with a future date defers the write and applies it once due", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const newLead = await teamLeadId(teamB.id);
|
||||
const employeeId = await freshEmployee(unitA.id);
|
||||
const target = await freshPosition(unitB.id);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id },
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
// Not written yet — this is the exact bug the migration fixes: a
|
||||
// future-dated transfer must not overwrite the live record today.
|
||||
const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(unchanged?.team_id).toBe(teamA.id);
|
||||
expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
@@ -78,7 +109,7 @@ describe("effective-dated mutations", () => {
|
||||
.eq("change_type", "transfer")
|
||||
.single();
|
||||
expect(pending?.status).toBe("pending");
|
||||
expect(pending?.payload.new_team_id).toBe(teamB.id);
|
||||
expect(pending?.payload.target_position_id).toBe(target);
|
||||
|
||||
// Fast-forward: simulate the effective date having arrived, then run
|
||||
// the same function the daily cron route calls.
|
||||
@@ -87,9 +118,14 @@ describe("effective-dated mutations", () => {
|
||||
expect(applyError).toBeNull();
|
||||
expect(appliedCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(employee?.team_id).toBe(teamB.id);
|
||||
expect(employee?.manager_id).toBe(newLead);
|
||||
const { data: assignment } = await adminClient
|
||||
.from("position_assignments")
|
||||
.select("position_id")
|
||||
.eq("employee_id", employeeId)
|
||||
.is("valid_to", null)
|
||||
.single();
|
||||
expect(assignment?.position_id).toBe(target);
|
||||
expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id);
|
||||
|
||||
const { data: appliedRow } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
@@ -101,7 +137,7 @@ describe("effective-dated mutations", () => {
|
||||
});
|
||||
|
||||
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const employeeId = await freshEmployee(unitA.id);
|
||||
|
||||
const { error } = await hrClient.rpc("promote_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
|
||||
@@ -126,7 +162,7 @@ describe("effective-dated mutations", () => {
|
||||
});
|
||||
|
||||
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const employeeId = await freshEmployee(unitA.id);
|
||||
const startDate = isoDateOffset(20);
|
||||
const returnDate = isoDateOffset(200);
|
||||
|
||||
|
||||
@@ -55,31 +55,57 @@ export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>
|
||||
return client;
|
||||
}
|
||||
|
||||
// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead
|
||||
// employee works for read/mutation tests that don't care which one.
|
||||
export async function pickSeededEmployee(
|
||||
filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {}
|
||||
): Promise<{
|
||||
// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie
|
||||
// kommt über die laufende Besetzung — deshalb liefert das hier gleich die
|
||||
// Planstelle und ihre Einheit mit.
|
||||
export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{
|
||||
id: string;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
manager_id: string | null;
|
||||
status: string;
|
||||
position_id: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
}> {
|
||||
let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1);
|
||||
if (filter.status) q = q.eq("status", filter.status);
|
||||
if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead);
|
||||
let q = adminClient
|
||||
.from("position_assignments")
|
||||
.select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)")
|
||||
.is("valid_to", null)
|
||||
.limit(1);
|
||||
if (filter.status) q = q.eq("employees.status", filter.status);
|
||||
if (filter.isChief !== undefined) q = q.eq("om_positions.is_chief", filter.isChief);
|
||||
|
||||
const { data, error } = await q.maybeSingle();
|
||||
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
|
||||
return data;
|
||||
const row = data as unknown as {
|
||||
employee_id: string;
|
||||
om_positions: { id: string; org_unit_id: string; is_chief: boolean };
|
||||
employees: { status: string };
|
||||
};
|
||||
return {
|
||||
id: row.employee_id,
|
||||
status: row.employees.status,
|
||||
position_id: row.om_positions.id,
|
||||
org_unit_id: row.om_positions.org_unit_id,
|
||||
is_chief: row.om_positions.is_chief,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> {
|
||||
const q = adminClient.from("teams").select("id").limit(2);
|
||||
const { data, error } = await q;
|
||||
if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`);
|
||||
const match = data.find((t) => t.id !== excludeTeamId) ?? data[0];
|
||||
return match;
|
||||
/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */
|
||||
export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> {
|
||||
const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2);
|
||||
if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`);
|
||||
return data.find((u) => u.id !== excludeUnitId) ?? data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine heute unbesetzte Planstelle. Einstellung und Versetzung setzen im
|
||||
* OM-Modell eine freie Zielplanstelle voraus — ohne die gibt es nichts zu
|
||||
* testen, deshalb legt der Aufrufer sonst selbst eine an.
|
||||
*/
|
||||
export async function pickVacantPosition(): Promise<{ id: string; org_unit_id: string } | null> {
|
||||
const { data: positions } = await adminClient.from("om_positions").select("id, org_unit_id").is("valid_to", null);
|
||||
const { data: taken } = await adminClient.from("position_assignments").select("position_id").is("valid_to", null);
|
||||
const besetzt = new Set((taken ?? []).map((a) => a.position_id));
|
||||
return (positions ?? []).find((p) => !besetzt.has(p.id)) ?? null;
|
||||
}
|
||||
|
||||
export async function pickSeededLocation(): Promise<{ id: string }> {
|
||||
@@ -88,18 +114,19 @@ export async function pickSeededLocation(): Promise<{ id: string }> {
|
||||
return data;
|
||||
}
|
||||
|
||||
// The seeded org guarantees exactly one active team lead per team (§2's
|
||||
// "reports-to" rule) — resolve_manager_for() relies on the same query.
|
||||
export async function teamLeadId(teamId: string): Promise<string | null> {
|
||||
/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */
|
||||
export async function chiefOfUnit(orgUnitId: string): Promise<string | null> {
|
||||
const { data, error } = await adminClient
|
||||
.from("employees")
|
||||
.select("id")
|
||||
.eq("team_id", teamId)
|
||||
.eq("is_lead", true)
|
||||
.neq("status", "Ausgetreten")
|
||||
.from("om_positions")
|
||||
.select("position_assignments!inner(employee_id, valid_to)")
|
||||
.eq("org_unit_id", orgUnitId)
|
||||
.eq("is_chief", true)
|
||||
.is("valid_to", null)
|
||||
.is("position_assignments.valid_to", null)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`);
|
||||
return data?.id ?? null;
|
||||
if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`);
|
||||
const row = data as unknown as { position_assignments: { employee_id: string }[] } | null;
|
||||
return row?.position_assignments[0]?.employee_id ?? null;
|
||||
}
|
||||
|
||||
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
|
||||
@@ -110,12 +137,13 @@ export function isoDateOffset(days: number): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Hires a throwaway employee into `teamId` via the real hire_employee RPC
|
||||
// (not a raw insert) so every mutation test starts from a state the app
|
||||
// itself can produce. Caller must clean up with deleteTestEmployee.
|
||||
// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte
|
||||
// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem
|
||||
// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit
|
||||
// deleteTestEmployee.
|
||||
export async function hireTestEmployee(
|
||||
hrClient: SupabaseClient<Database>,
|
||||
teamId: string,
|
||||
positionId: string,
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Promise<string> {
|
||||
const location = await pickSeededLocation();
|
||||
@@ -125,8 +153,9 @@ export async function hireTestEmployee(
|
||||
gender: "w",
|
||||
birth_date: "1990-01-01",
|
||||
location_id: location.id,
|
||||
team_id: teamId,
|
||||
job_title: "Integrationstest-Rolle",
|
||||
// Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht
|
||||
// mitgegeben, sonst könnten die beiden auseinanderlaufen.
|
||||
position_id: positionId,
|
||||
entry_date: isoDateOffset(-30),
|
||||
source: "Extern",
|
||||
...overrides,
|
||||
@@ -145,21 +174,19 @@ export async function deleteTestEmployee(employeeId: string): Promise<void> {
|
||||
await adminClient.from("employees").delete().eq("id", employeeId);
|
||||
}
|
||||
|
||||
// Creates a throwaway open position via the real create_position RPC.
|
||||
// Defaults to a non-lead position reporting to `superiorEmployeeId` (its
|
||||
// team is derived from that employee's own team, same as the app does).
|
||||
// Caller must clean up with deleteTestPosition — and, since positions.
|
||||
// reports_to_employee_id / filled_by_employee_id reference employees(id)
|
||||
// with no cascade, delete positions before the employees they point to.
|
||||
// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte
|
||||
// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben —
|
||||
// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und
|
||||
// zwar *vor* den Personen, die darauf sassen.
|
||||
export async function createTestPosition(
|
||||
hrClient: SupabaseClient<Database>,
|
||||
superiorEmployeeId: string,
|
||||
orgUnitId: string,
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Promise<string> {
|
||||
const payload = {
|
||||
title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`,
|
||||
superior_employee_id: superiorEmployeeId,
|
||||
is_lead: false,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`,
|
||||
is_chief: false,
|
||||
...overrides,
|
||||
};
|
||||
const { data, error } = await hrClient.rpc("create_position", { payload });
|
||||
@@ -168,5 +195,7 @@ export async function createTestPosition(
|
||||
}
|
||||
|
||||
export async function deleteTestPosition(positionId: string): Promise<void> {
|
||||
await adminClient.from("positions").delete().eq("id", positionId);
|
||||
// Besetzungen hängen mit on delete cascade daran, der Job bleibt im
|
||||
// Katalog — er ist geteilt und gehört keiner einzelnen Planstelle.
|
||||
await adminClient.from("om_positions").delete().eq("id", positionId);
|
||||
}
|
||||
|
||||
216
tests/integration/position-assignments.test.ts
Normal file
216
tests/integration/position-assignments.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
teamLeadId,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Reorg scenarios: immediate apply/undo must respect employee_history's
|
||||
// append-only contract (20260714120300_reorg_undo_append_only.sql), and a
|
||||
// future-dated scenario must defer every move via pending_org_changes until
|
||||
// its effective date, flipping reorg_scenarios.applied only once every move
|
||||
// has landed (20260714120200_effective_dating_rpcs.sql).
|
||||
describe("reorg scenarios", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(0),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
expect(scenarioId).toBeTruthy();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenario } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenario?.applied).toBe(true);
|
||||
expect(scenario?.applied_at).not.toBeNull();
|
||||
|
||||
const { count: historyBeforeUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyBeforeUndo).toBe(1);
|
||||
|
||||
const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } });
|
||||
expect(undoError).toBeNull();
|
||||
|
||||
const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(revertedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single();
|
||||
expect(undoneScenario?.applied).toBe(false);
|
||||
|
||||
// The original "Reorganisation" row must still be there — undo appends
|
||||
// a compensating entry, it never deletes (the bug the migration fixed).
|
||||
const { count: historyAfterUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyAfterUndo).toBe(2);
|
||||
});
|
||||
|
||||
it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(30),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
|
||||
const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(unchangedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: scenarioBefore } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioBefore?.applied).toBe(false);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("reorg_scenario_id", scenarioId as string)
|
||||
.eq("status", "pending")
|
||||
.single();
|
||||
expect(pending).not.toBeNull();
|
||||
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
const { error: cronError } = await adminClient.rpc("apply_due_pending_changes");
|
||||
expect(cronError).toBeNull();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenarioAfter } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioAfter?.applied).toBe(true);
|
||||
expect(scenarioAfter?.applied_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
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, deleteTestEmployee, deleteTestUser, hireTestEmployee, pickSeededTeam, signInAs, type TestUser } from "./helpers";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
createTestPosition,
|
||||
deleteTestEmployee,
|
||||
deleteTestPosition,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededUnit,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
|
||||
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
|
||||
// Enforced by a trigger, so these drive it through the real write paths and
|
||||
@@ -9,8 +21,9 @@ import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTest
|
||||
describe("SVNR validation", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let team: { id: string };
|
||||
let unit: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
const positionIds: string[] = [];
|
||||
|
||||
// 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7
|
||||
const VALID = "1237 010180";
|
||||
@@ -25,16 +38,21 @@ describe("SVNR validation", () => {
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
team = await pickSeededTeam();
|
||||
unit = await pickSeededUnit();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, team.id, {
|
||||
// Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der
|
||||
// ersten besetzt.
|
||||
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
const id = await hireTestEmployee(hrClient, positionId, {
|
||||
location_id: await locationIn(country),
|
||||
birth_date: BIRTH_DATE,
|
||||
...overrides,
|
||||
|
||||
@@ -1,52 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { ancestorsOf, breadcrumbLabel, buildOrgMaps, divisionOf, subtreeOf, type OrgUnit } from "@/lib/org";
|
||||
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
// Der Baum ersetzt die drei festen Ebenen des Altmodells. Damit hängt an
|
||||
// dieser Datei mehr als vorher: eine Einheit falsch verkettet, und ein Filter
|
||||
// „Bereich Produktion" liefert stillschweigend zu wenig — nicht gar nichts,
|
||||
// was auffallen würde.
|
||||
|
||||
const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" };
|
||||
const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" };
|
||||
const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" };
|
||||
const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" };
|
||||
const units: OrgUnit[] = [
|
||||
{ id: "gmbh", org_number: "10000000", name: "Alpenwerk", parent_id: null, unit_type: "Gesellschaft" },
|
||||
{ id: "prod", org_number: "20100000", name: "Produktion", parent_id: "gmbh", unit_type: "Bereich" },
|
||||
{ id: "fert", org_number: "21100000", name: "Fertigung", parent_id: "prod", unit_type: "Abteilung" },
|
||||
{ id: "mont", org_number: "22001000", name: "Montage", parent_id: "fert", unit_type: "Team" },
|
||||
{ id: "cnc", org_number: "22002000", name: "CNC", parent_id: "fert", unit_type: "Team" },
|
||||
{ id: "it", org_number: "20200000", name: "IT", parent_id: "gmbh", unit_type: "Bereich" },
|
||||
];
|
||||
const locations = [{ id: "loc-1", name: "Wien-Hernals", country: "Österreich" }];
|
||||
const maps = buildOrgMaps(units, locations);
|
||||
|
||||
const orgMaps: OrgMaps = {
|
||||
divisions: new Map([[division.id, division]]),
|
||||
departments: new Map([[department.id, department]]),
|
||||
teams: new Map([[team.id, team]]),
|
||||
locations: new Map([[location.id, location]]),
|
||||
divisionList: [division],
|
||||
locationList: [location],
|
||||
};
|
||||
|
||||
describe("breadcrumbFor", () => {
|
||||
it("resolves division, department (via team), and team from ids", () => {
|
||||
const result = breadcrumbFor(orgMaps, division.id, team.id);
|
||||
expect(result.division?.name).toBe("Produktion");
|
||||
expect(result.department?.name).toBe("Fertigung");
|
||||
expect(result.team?.name).toBe("Montage");
|
||||
describe("buildOrgMaps", () => {
|
||||
it("listet Eltern vor ihren Kindern", () => {
|
||||
// Die Reihenfolge ist eine Zusage: der Filter im Mitarbeiterlisten-Select
|
||||
// rückt danach ein, und ein Einfügen in die Datenbank verlässt sich darauf.
|
||||
const position = new Map(maps.unitList.map((u, i) => [u.id, i]));
|
||||
for (const u of maps.unitList) {
|
||||
if (!u.parent_id) continue;
|
||||
expect(position.get(u.parent_id)!, `${u.name} steht vor ${u.parent_id}`).toBeLessThan(position.get(u.id)!);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no team/department for a division head with no team (team_id null)", () => {
|
||||
const result = breadcrumbFor(orgMaps, division.id, null);
|
||||
expect(result.division?.name).toBe("Produktion");
|
||||
expect(result.team).toBeUndefined();
|
||||
expect(result.department).toBeUndefined();
|
||||
it("misst die Tiefe ab der Wurzel", () => {
|
||||
expect(maps.depthOf.get("gmbh")).toBe(0);
|
||||
expect(maps.depthOf.get("prod")).toBe(1);
|
||||
expect(maps.depthOf.get("mont")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ancestorsOf", () => {
|
||||
it("gibt die Kette von der Wurzel bis zur Einheit selbst", () => {
|
||||
expect(ancestorsOf(maps, "mont").map((u) => u.name)).toEqual(["Alpenwerk", "Produktion", "Fertigung", "Montage"]);
|
||||
});
|
||||
|
||||
it("bricht bei einem Ring ab, statt endlos zu laufen", () => {
|
||||
// parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann
|
||||
// einen Ring erzeugen, und jede Auswertung hier ist rekursiv.
|
||||
const ringMaps = buildOrgMaps(
|
||||
[
|
||||
{ id: "a", org_number: "1", name: "A", parent_id: "b", unit_type: "Bereich" },
|
||||
{ id: "b", org_number: "2", name: "B", parent_id: "a", unit_type: "Bereich" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
expect(ancestorsOf(ringMaps, "a")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("liefert nichts für eine unbekannte Einheit", () => {
|
||||
expect(ancestorsOf(maps, "gibtesnicht")).toEqual([]);
|
||||
expect(ancestorsOf(maps, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtreeOf", () => {
|
||||
it("schliesst die Einheit selbst und alles darunter ein", () => {
|
||||
// Genau das meint der Filter „Bereich Produktion": im Bereich selbst
|
||||
// sitzt nur die Bereichsleitung, alle anderen hängen tiefer.
|
||||
expect(new Set(subtreeOf(maps, "prod"))).toEqual(new Set(["prod", "fert", "mont", "cnc"]));
|
||||
});
|
||||
|
||||
it("ist für ein Blatt die Einheit allein", () => {
|
||||
expect(subtreeOf(maps, "mont")).toEqual(["mont"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("divisionOf", () => {
|
||||
it("findet den Bereich einer tief hängenden Einheit", () => {
|
||||
expect(divisionOf(maps, "mont")?.name).toBe("Produktion");
|
||||
});
|
||||
|
||||
it("gibt für eine Bereichsleitung ihren eigenen Bereich", () => {
|
||||
expect(divisionOf(maps, "prod")?.name).toBe("Produktion");
|
||||
});
|
||||
|
||||
it("hat für die Gesellschaft selbst keinen Bereich", () => {
|
||||
// Die Geschäftsführung sitzt über allen Bereichen, nicht in einem.
|
||||
expect(divisionOf(maps, "gmbh")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("breadcrumbLabel", () => {
|
||||
it("joins division › department › team with the SAP-OM breadcrumb separator", () => {
|
||||
expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion › Fertigung › Montage");
|
||||
it("lässt die Gesellschaft weg, die in jeder Zeile gleich wäre", () => {
|
||||
expect(breadcrumbLabel(maps, "mont")).toBe("Produktion › Fertigung › Montage");
|
||||
});
|
||||
|
||||
it("omits missing segments instead of producing empty separators", () => {
|
||||
expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion");
|
||||
it("endet bei der Einheit, an der die Person tatsächlich hängt", () => {
|
||||
expect(breadcrumbLabel(maps, "prod")).toBe("Produktion");
|
||||
});
|
||||
|
||||
it("falls back to a dash when nothing resolves", () => {
|
||||
expect(breadcrumbLabel(orgMaps, null, null)).toBe("–");
|
||||
it("fällt auf einen Strich zurück, wenn nichts auflösbar ist", () => {
|
||||
expect(breadcrumbLabel(maps, null)).toBe("–");
|
||||
expect(breadcrumbLabel(maps, "gmbh")).toBe("–");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||||
first_name: "Maria",
|
||||
last_name: "Gruber",
|
||||
job_title: "Maschinenbediener:in",
|
||||
division_id: "div-1",
|
||||
team_id: "team-1",
|
||||
org_unit_id: "team-1",
|
||||
location_id: "loc-1",
|
||||
status: "Aktiv",
|
||||
employment_type: "Vollzeit",
|
||||
@@ -43,9 +42,16 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||||
};
|
||||
}
|
||||
|
||||
// Alle drei sind über die Einheit geschlüsselt, in der die Person sitzt:
|
||||
// "team-1" hängt unter der Abteilung Fertigung im Bereich Produktion.
|
||||
// "div-1" ist der Bereich selbst — dort sitzt nur die Bereichsleitung, die
|
||||
// weder Abteilung noch Team hat.
|
||||
const lookups: OrgLookups = {
|
||||
divisionName: new Map([["div-1", "Produktion"]]),
|
||||
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
|
||||
divisionName: new Map([
|
||||
["team-1", "Produktion"],
|
||||
["div-1", "Produktion"],
|
||||
]),
|
||||
departmentName: new Map([["team-1", "Fertigung"]]),
|
||||
teamName: new Map([["team-1", "Montage"]]),
|
||||
locationName: new Map([["loc-1", "Wien-Hernals"]]),
|
||||
};
|
||||
@@ -65,14 +71,23 @@ describe("groupKeyFor", () => {
|
||||
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
|
||||
});
|
||||
|
||||
it("falls back to a dash for department/team when the employee has no team", () => {
|
||||
const e = emp({ team_id: null });
|
||||
it("falls back to a dash for department/team for somebody sitting at the division itself", () => {
|
||||
// Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher
|
||||
// liess sich das nur über team_id = null ausdrücken.
|
||||
const e = emp({ org_unit_id: "div-1" });
|
||||
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
|
||||
expect(groupKeyFor(e, "department", lookups)).toBe("–");
|
||||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||||
});
|
||||
|
||||
it("falls back to a dash when somebody held no position at all", () => {
|
||||
const e = emp({ org_unit_id: null });
|
||||
expect(groupKeyFor(e, "division", lookups)).toBe("–");
|
||||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||||
});
|
||||
|
||||
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
|
||||
const e = emp({ division_id: "ghost", location_id: "ghost" });
|
||||
const e = emp({ org_unit_id: "ghost", location_id: "ghost" });
|
||||
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
|
||||
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
|
||||
});
|
||||
@@ -166,15 +181,17 @@ describe("measureValue", () => {
|
||||
describe("aggregateReport", () => {
|
||||
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
|
||||
const employees = [
|
||||
emp({ id: "1", division_id: "div-1" }),
|
||||
emp({ id: "2", division_id: "div-1" }),
|
||||
emp({ id: "3", division_id: "div-2" }),
|
||||
emp({ id: "1", org_unit_id: "team-1" }),
|
||||
emp({ id: "2", org_unit_id: "team-1" }),
|
||||
emp({ id: "3", org_unit_id: "team-2" }),
|
||||
];
|
||||
// Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt
|
||||
// unter Produktion, team-2 unter IT.
|
||||
const multiDivisionLookups: OrgLookups = {
|
||||
...lookups,
|
||||
divisionName: new Map([
|
||||
["div-1", "Produktion"],
|
||||
["div-2", "IT"],
|
||||
["team-1", "Produktion"],
|
||||
["team-2", "IT"],
|
||||
]),
|
||||
};
|
||||
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
|
||||
@@ -229,7 +246,7 @@ describe("aggregateReport", () => {
|
||||
});
|
||||
|
||||
it("sorts a weekday split chronologically within each group", () => {
|
||||
const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })];
|
||||
const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })];
|
||||
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
|
||||
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
|
||||
});
|
||||
@@ -259,8 +276,8 @@ describe("aggregateEvents", () => {
|
||||
const eventLookups: OrgLookups = {
|
||||
...lookups,
|
||||
divisionName: new Map([
|
||||
["div-1", "Produktion"],
|
||||
["div-2", "IT"],
|
||||
["team-1", "Produktion"],
|
||||
["team-2", "IT"],
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -270,8 +287,7 @@ describe("aggregateEvents", () => {
|
||||
first_name: "Maria",
|
||||
last_name: "Gruber",
|
||||
job_title: "Maschinenbediener:in",
|
||||
division_id: "div-1",
|
||||
team_id: "team-1",
|
||||
org_unit_id: "team-1",
|
||||
location_id: "loc-1",
|
||||
event_date: "2026-03-01",
|
||||
event_type: "Eintritt",
|
||||
@@ -281,14 +297,14 @@ describe("aggregateEvents", () => {
|
||||
}
|
||||
|
||||
it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => {
|
||||
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", division_id: "div-2" })];
|
||||
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", org_unit_id: "team-2" })];
|
||||
const rows = aggregateEvents(events, "event_type", null, eventLookups);
|
||||
expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 });
|
||||
expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 });
|
||||
});
|
||||
|
||||
it("groups by the affected employee's org unit and preserves the event description on drill-down", () => {
|
||||
const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })];
|
||||
const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })];
|
||||
const rows = aggregateEvents(events, "division", null, eventLookups);
|
||||
const produktion = rows.find((r) => r.key === "Produktion")!;
|
||||
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });
|
||||
|
||||
Reference in New Issue
Block a user