Constructing the tree is where parent links, chief positions and number ranges get wired up wrongly without anyone noticing — a team under the wrong Bereich looks perfectly plausible in the org chart. So the construction is a pure function taking an id generator, and the checks that matter are asserted rather than eyeballed: exactly one root, every unit's parent of the expected type, every unit reaching the root, one chief position per unit, unique numbers in the right ranges, and every position pointing at a real unit and a real job. Also introduces the job catalogue this model needs. job_title was free text per person, so "Schlosser:in" and "Schlosser" could coexist and no breakdown by occupation was possible; jobs are now deduplicated by title and shared across positions. Every Abteilung gets a chief position, which is the level the old three-table model had no room for. Correction to the previous commit message: it claimed the cut-over could follow later while the old tables kept working. It cannot. The legacy resolve_manager_for() finds a Bereichsleitung by "division_id = X and team_id is null and org_level = 1", and an Abteilungsleitung satisfies the same predicate — its LIMIT 1 would then pick one of the two arbitrarily. The two models cannot both be correct once the new level is populated, so the remaining work is a single cut across the 11 RPCs that read the legacy columns, not a gradual migration.
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { buildOrg, type DivisionDef } from "@/supabase/build-org";
|
|
|
|
// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge
|
|
// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt:
|
|
// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus.
|
|
|
|
const DIVISIONS: DivisionDef[] = [
|
|
{
|
|
name: "Produktion",
|
|
headTitle: "Bereichsleitung Produktion",
|
|
departments: [
|
|
{
|
|
name: "Fertigung",
|
|
leadTitle: "Abteilungsleitung Fertigung",
|
|
teams: [
|
|
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 },
|
|
{ name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 },
|
|
],
|
|
},
|
|
{
|
|
name: "Instandhaltung",
|
|
leadTitle: "Abteilungsleitung Instandhaltung",
|
|
teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
name: "IT",
|
|
headTitle: "Bereichsleitung IT",
|
|
departments: [
|
|
{
|
|
name: "Infrastruktur",
|
|
leadTitle: "Abteilungsleitung Infrastruktur",
|
|
teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
function build() {
|
|
let n = 0;
|
|
return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`);
|
|
}
|
|
|
|
describe("Struktur", () => {
|
|
it("hat genau eine Wurzel, und das ist die Gesellschaft", () => {
|
|
const { units } = build();
|
|
const roots = units.filter((u) => u.parent_id === null);
|
|
expect(roots).toHaveLength(1);
|
|
expect(roots[0].unit_type).toBe("Gesellschaft");
|
|
});
|
|
|
|
it("hängt jede Einheit unter den richtigen Typ", () => {
|
|
const { units } = build();
|
|
const byId = new Map(units.map((u) => [u.id, u]));
|
|
const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const;
|
|
|
|
for (const u of units) {
|
|
if (u.unit_type === "Gesellschaft") continue;
|
|
const parent = byId.get(u.parent_id!);
|
|
expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]);
|
|
}
|
|
});
|
|
|
|
it("baut die Ebenen vollständig auf", () => {
|
|
const { units } = build();
|
|
const zahl = (t: string) => units.filter((u) => u.unit_type === t).length;
|
|
expect(zahl("Gesellschaft")).toBe(1);
|
|
expect(zahl("Bereich")).toBe(2);
|
|
expect(zahl("Abteilung")).toBe(3);
|
|
expect(zahl("Team")).toBe(4);
|
|
});
|
|
|
|
it("führt jede Einheit über parent_id auf die Wurzel zurück", () => {
|
|
// Ein abgehängter Teilbaum würde im Organigramm nie gerendert.
|
|
const { units } = build();
|
|
const byId = new Map(units.map((u) => [u.id, u]));
|
|
for (const start of units) {
|
|
const gesehen = new Set<string>();
|
|
let cur = start;
|
|
while (cur.parent_id && !gesehen.has(cur.id)) {
|
|
gesehen.add(cur.id);
|
|
cur = byId.get(cur.parent_id)!;
|
|
}
|
|
expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => {
|
|
const { units } = build();
|
|
expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length);
|
|
const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const;
|
|
for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Planstellen", () => {
|
|
it("gibt jeder Einheit genau eine Leitungsplanstelle", () => {
|
|
// Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll
|
|
// der Seed gar nicht erst dagegenlaufen.
|
|
const { units, positions } = build();
|
|
for (const u of units) {
|
|
const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief);
|
|
expect(chiefs, `${u.name}`).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it("legt auch für die Abteilung eine Leitung an", () => {
|
|
// Die Ebene, die im Altmodell gefehlt hat.
|
|
const { units, positions } = build();
|
|
const abteilungen = units.filter((u) => u.unit_type === "Abteilung");
|
|
expect(abteilungen.length).toBeGreaterThan(0);
|
|
for (const a of abteilungen) {
|
|
expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => {
|
|
const { units, icPositionsByTeam } = build();
|
|
const montage = units.find((u) => u.name === "Montage")!;
|
|
expect(icPositionsByTeam.get(montage.id)).toHaveLength(3);
|
|
expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true);
|
|
});
|
|
|
|
it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => {
|
|
const { positions } = build();
|
|
expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length);
|
|
for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/);
|
|
});
|
|
|
|
it("hängt jede Planstelle an eine existierende Einheit", () => {
|
|
const { units, positions } = build();
|
|
const ids = new Set(units.map((u) => u.id));
|
|
for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Job-Katalog", () => {
|
|
it("führt jede Tätigkeit genau einmal", () => {
|
|
// "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine
|
|
// Auswertung nach Tätigkeit wertlos.
|
|
const { jobs } = build();
|
|
expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length);
|
|
expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length);
|
|
});
|
|
|
|
it("teilt denselben Job über mehrere Planstellen", () => {
|
|
const { positions, jobs } = build();
|
|
const jobById = new Map(jobs.map((j) => [j.id, j]));
|
|
const monteur = jobs.find((j) => j.title === "Monteur:in")!;
|
|
const stellen = positions.filter((p) => p.job_id === monteur.id);
|
|
expect(stellen.length).toBeGreaterThan(1);
|
|
expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in");
|
|
});
|
|
|
|
it("verweist jede Planstelle auf einen existierenden Job", () => {
|
|
const { positions, jobs } = build();
|
|
const ids = new Set(jobs.map((j) => j.id));
|
|
for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true);
|
|
});
|
|
});
|