Build the org tree in the OM model as a tested pure function

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.
This commit is contained in:
2026-07-27 12:52:02 +02:00
parent 4b9c23472c
commit 35f17858d8
2 changed files with 311 additions and 0 deletions

149
supabase/build-org.ts Normal file
View File

@@ -0,0 +1,149 @@
// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen
// Bereichsdefinition des Seeds.
//
// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die
// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen
// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem
// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm
// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] };
export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
export type BuiltUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
export type BuiltJob = { id: string; code: string; title: string };
export type BuiltPosition = {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
};
export type BuiltOrg = {
units: BuiltUnit[];
jobs: BuiltJob[];
positions: BuiltPosition[];
/** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */
icPositionsByTeam: Map<string, BuiltPosition[]>;
};
// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den
// Umstieg hinweg wiedererkennbar bleiben.
const PREFIX: Record<OrgUnitType, string> = {
Gesellschaft: "10",
Bereich: "20",
Abteilung: "21",
Team: "22",
};
function orgNumber(type: OrgUnitType, counter: number): string {
return `${PREFIX[type]}${String(counter).padStart(6, "0")}`;
}
/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */
function positionNumber(counter: number): string {
return `6${String(counter).padStart(7, "0")}`;
}
function jobCode(counter: number): string {
return `J${String(counter).padStart(4, "0")}`;
}
/**
* `newId` wird hereingereicht, damit die Funktion in Tests deterministisch
* bleibt und im Seed randomUUID benutzt.
*/
export function buildOrg(
companyName: string,
divisions: DivisionDef[],
newId: () => string
): BuiltOrg {
const units: BuiltUnit[] = [];
const positions: BuiltPosition[] = [];
const icPositionsByTeam = new Map<string, BuiltPosition[]>();
// Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title
// Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen.
const jobIdByTitle = new Map<string, string>();
const jobs: BuiltJob[] = [];
function jobFor(title: string): string {
const existing = jobIdByTitle.get(title);
if (existing) return existing;
const job = { id: newId(), code: jobCode(jobs.length + 1), title };
jobs.push(job);
jobIdByTitle.set(title, job.id);
return job.id;
}
let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 };
let positionCounter = 0;
function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit {
unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 };
const unit: BuiltUnit = {
id: newId(),
org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)),
name,
parent_id: parentId,
unit_type: type,
};
units.push(unit);
return unit;
}
function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition {
positionCounter += 1;
const position: BuiltPosition = {
id: newId(),
position_number: positionNumber(positionCounter),
org_unit_id: unitId,
job_id: jobFor(title),
is_chief: isChief,
};
positions.push(position);
return position;
}
const company = addUnit(companyName, "Gesellschaft", null);
addPosition(company.id, "Geschäftsführer:in", true);
// Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau
// die Unterscheidung, die es im Altmodell nicht gab.
addPosition(company.id, "Assistenz der Geschäftsführung", false);
for (const div of divisions) {
const bereich = addUnit(div.name, "Bereich", company.id);
addPosition(bereich.id, div.headTitle, true);
for (const dept of div.departments) {
const abteilung = addUnit(dept.name, "Abteilung", bereich.id);
// Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt
// eine eigene Leitungsplanstelle.
addPosition(abteilung.id, dept.leadTitle, true);
for (const team of dept.teams) {
const teamUnit = addUnit(team.name, "Team", abteilung.id);
addPosition(teamUnit.id, team.leadTitle, true);
const size = Math.max(1, team.baseSize);
const icPositions = Array.from({ length: size }, (_, i) =>
addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false)
);
icPositionsByTeam.set(teamUnit.id, icPositions);
}
}
}
return { units, jobs, positions, icPositionsByTeam };
}

View File

@@ -0,0 +1,162 @@
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);
});
});