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.
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
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";
|
|
|
|
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
|
|
// Enforced by a trigger, so these drive it through the real write paths and
|
|
// through a direct update — both must be covered by the same rule.
|
|
describe("SVNR validation", () => {
|
|
let hrUser: TestUser;
|
|
let hrClient: SupabaseClient<Database>;
|
|
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";
|
|
const BIRTH_DATE = "1980-01-01";
|
|
|
|
async function locationIn(country: string): Promise<string> {
|
|
const { data } = await adminClient.from("locations").select("id").eq("country", country).limit(1).maybeSingle();
|
|
if (!data) throw new Error(`no seeded location in ${country}`);
|
|
return data.id;
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
|
|
// 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,
|
|
});
|
|
employeeIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
describe("is_valid_svnr", () => {
|
|
async function check(svnr: string, birthDate: string | null = null): Promise<boolean> {
|
|
const { data, error } = await adminClient.rpc("is_valid_svnr", { p_svnr: svnr, p_birth_date: birthDate });
|
|
if (error) throw new Error(error.message);
|
|
return data as unknown as boolean;
|
|
}
|
|
|
|
it("matches the TypeScript implementation on the documented cases", async () => {
|
|
expect(await check(VALID)).toBe(true);
|
|
expect(await check("1237010180")).toBe(true);
|
|
expect(await check("1234 010180")).toBe(false); // wrong check digit
|
|
expect(await check("0007 010180")).toBe(false); // 000 serial
|
|
expect(await check("0040 010180")).toBe(false); // check digit would be 10
|
|
expect(await check("1237 011380")).toBe(false); // month 13
|
|
expect(await check("123701018")).toBe(false); // nine digits
|
|
});
|
|
|
|
it("cross-checks the birth date when one is given", async () => {
|
|
expect(await check(VALID, BIRTH_DATE)).toBe(true);
|
|
expect(await check(VALID, "1980-01-02")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("at an Austrian location", () => {
|
|
it("accepts a hire carrying a valid number", async () => {
|
|
const id = await hireAt("Österreich", { sv_nummer: VALID });
|
|
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
|
expect(data?.sv_nummer).toBe(VALID);
|
|
});
|
|
|
|
it("rejects a hire carrying an invalid number", async () => {
|
|
await expect(hireAt("Österreich", { sv_nummer: "1234 010180" })).rejects.toThrow(/SV-Nummer/i);
|
|
});
|
|
|
|
it("rejects a number whose birth date disagrees with the employee record", async () => {
|
|
await expect(hireAt("Österreich", { sv_nummer: VALID, birth_date: "1975-06-30" })).rejects.toThrow(/SV-Nummer/i);
|
|
});
|
|
|
|
it("allows the field to stay empty", async () => {
|
|
const id = await hireAt("Österreich");
|
|
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
|
expect(data?.sv_nummer ?? null).toBeNull();
|
|
});
|
|
|
|
it("rejects an update to an invalid number", async () => {
|
|
const id = await hireAt("Österreich", { sv_nummer: VALID });
|
|
const { error } = await adminClient.from("employees").update({ sv_nummer: "9999 010180" }).eq("id", id);
|
|
expect(error?.message ?? "").toMatch(/SV-Nummer/i);
|
|
});
|
|
});
|
|
|
|
describe("outside Austria", () => {
|
|
it("leaves the field free-form", async () => {
|
|
// The German equivalent has a different format entirely; validating it
|
|
// against the Austrian standard would reject correct data.
|
|
const id = await hireAt("Deutschland", { sv_nummer: "12 345678 A 901" });
|
|
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
|
expect(data?.sv_nummer).toBe("12 345678 A 901");
|
|
});
|
|
});
|
|
|
|
describe("legacy rows", () => {
|
|
it("does not block an unrelated edit when the stored number is invalid", async () => {
|
|
// Rows predating the migration hold unvalidated values. A transfer or
|
|
// a name change must not fail because of a number nobody is touching.
|
|
const id = await hireAt("Deutschland", { sv_nummer: "0000 000000" });
|
|
const { error: moveError } = await adminClient
|
|
.from("employees")
|
|
.update({ location_id: await locationIn("Österreich") })
|
|
.eq("id", id);
|
|
expect(moveError).toBeNull();
|
|
|
|
const { error: nameError } = await adminClient.from("employees").update({ phone: "+43 1 9999999" }).eq("id", id);
|
|
expect(nameError).toBeNull();
|
|
});
|
|
});
|
|
});
|