diff --git a/supabase/build-org.ts b/supabase/build-org.ts new file mode 100644 index 0000000..22befda --- /dev/null +++ b/supabase/build-org.ts @@ -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; +}; + +// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den +// Umstieg hinweg wiedererkennbar bleiben. +const PREFIX: Record = { + 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(); + + // 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(); + 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 }; +} diff --git a/tests/unit/build-org.test.ts b/tests/unit/build-org.test.ts new file mode 100644 index 0000000..c8f77d9 --- /dev/null +++ b/tests/unit/build-org.test.ts @@ -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(); + 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); + }); +});