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:
2026-07-27 20:02:26 +02:00
parent 4929252f45
commit 27669e0359
41 changed files with 2203 additions and 2100 deletions

View File

@@ -1,32 +1,35 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee } from "@/components/orgchart/types";
import { resolveActingManagers } from "./acting-manager";
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
import { todayIso } from "./format";
import { deriveStatusAsOf } from "./reports";
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// The Organigramm as it stood (or will stand) on a given date. Three sources
// have to be reconciled, because no single one covers the whole timeline:
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
//
// past/today employee_assignments — the interval covering `asOf`
// future pending_org_changes — effective-dated moves not yet applied
// membership entry/exit/karenz — who counted as staff on that date
// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den
// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die
// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die
// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte.
//
// See supabase/migrations/*_employee_assignment_history.sql for why the
// placement timeline is captured by a trigger rather than per-RPC.
// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also
// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche
// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem
// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite
// Zugehörigkeitsregel braucht es nicht mehr.
//
// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht
// in position_assignments, weil sie erst am Stichtag geschrieben werden.
const ORG_COLUMNS =
"id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type";
/** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
/** Änderungsarten, die jemanden in der Organisation verschieben. */
const PLACEMENT_CHANGES = ["transfer"] as const;
export type OrgAsOfResult = {
employees: OrgEmployee[];
/** How many placements were projected from not-yet-applied changes. */
vacancies: OrgVacancy[];
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
projectedCount: number;
/** Earliest date the assignment history actually covers. */
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */
historyStartsAt: string | null;
};
@@ -36,196 +39,172 @@ type EmployeeRow = {
first_name: string;
last_name: string;
job_title: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
};
type AssignmentRow = {
employee_id: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string;
type PositionRow = {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
jobs: { title: string };
};
type AssignmentRow = { employee_id: string; position_id: string };
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
// their team's lead, a team lead to the division head, and anyone without a
// team to the CEO. Only used for employees a pending change actually moves —
// everyone else keeps the manager recorded on their assignment, so existing
// data that deviates from the rule is never silently "corrected".
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
if (placement.team_id && !placement.is_lead) {
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
}
if (placement.team_id && placement.is_lead) {
return (
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
null
);
}
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
}
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
const today = todayIso();
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")),
fetchAllRows(() =>
supabase
.from("employee_assignments")
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
.from("om_positions")
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("id")
),
fetchAllRows(() =>
supabase
.from("position_assignments")
.select("employee_id, position_id")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("employee_id")
),
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
fetchAllRows(() =>
supabase
.from("employees")
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type")
.order("id")
),
asOf > today
? fetchAllRows(() =>
supabase
.from("pending_org_changes")
.select("employee_id, change_type, effective_date, payload")
.select("employee_id, effective_date, payload")
.eq("status", "pending")
.lte("effective_date", asOf)
.in("change_type", [...PLACEMENT_CHANGES])
.order("effective_date")
)
: Promise.resolve([]),
supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(),
]);
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
return resolveOrgSnapshot({
asOf,
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
positions: positions as unknown as PositionRow[],
assignments: assignments as AssignmentRow[],
employees: employees as EmployeeRow[],
pending: pending as PendingRow[],
historyStartsAt: earliest.data?.valid_from ?? null,
});
}
// The pure half of the above: everything that turns the four row sets into a
// snapshot, with no Supabase client in sight, so the reconciliation rules can
// be tested directly.
/**
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die
* Regeln direkt prüfbar sind.
*/
export function resolveOrgSnapshot({
asOf,
employees: allEmployees,
units,
positions,
assignments,
teams,
departments,
employees: allEmployees,
pending,
historyStartsAt,
}: {
asOf: string;
employees: EmployeeRow[];
units: OmUnit[];
positions: PositionRow[];
assignments: AssignmentRow[];
teams: { id: string; department_id: string }[];
departments: { id: string; division_id: string }[];
employees: EmployeeRow[];
pending: PendingRow[];
historyStartsAt: string | null;
}): OrgAsOfResult {
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
// A projected move names only the target team; its division follows from
// the team's department, the same way the DB trigger derives it.
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const teamDivision = new Map(
teams.flatMap((t) => {
const divisionId = departmentDivision.get(t.department_id);
return divisionId ? [[t.id, divisionId] as const] : [];
})
);
const positionById = new Map(positions.map((p) => [p.id, p]));
const employeeById = new Map(allEmployees.map((e) => [e.id, e]));
// Employed (or on leave) on that date — the same derivation the Berichte
// page uses, so the two can never disagree on who counted when.
const staff = allEmployees.filter((e) => {
const status = deriveStatusAsOf(e, asOf);
return status === "Aktiv" || status === "Karenz";
});
// Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(),
// damit die drei nie auseinanderlaufen können.
const isAbsent = (e: EmployeeRow) =>
e.karenz_start_date !== null &&
e.karenz_start_date <= asOf &&
(e.karenz_return_date === null || asOf < e.karenz_return_date);
const resolved = staff.map((e) => {
const a = assignmentByEmployee.get(e.id);
return {
employee: e,
managerId: a ? a.manager_id : e.manager_id,
placement: {
team_id: a ? a.team_id : e.team_id,
division_id: a ? a.division_id : e.division_id,
job_title: a ? a.job_title : e.job_title,
is_lead: a ? a.is_lead : e.is_lead,
org_level: a ? a.org_level : e.org_level,
} satisfies Placement,
};
});
const positionOf = new Map<string, string>();
for (const a of assignments) {
if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id);
}
// Project the future. Ordered by effective_date, so a later move wins.
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
// Versetzung gewinnt.
const moved = new Set<string>();
for (const change of pending) {
const target = byId.get(change.employee_id);
if (!target) continue;
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
if (newTeamId) {
target.placement.team_id = newTeamId;
const divisionId = teamDivision.get(newTeamId);
if (divisionId) target.placement.division_id = divisionId;
moved.add(target.employee.id);
}
if (payload.new_title) target.placement.job_title = payload.new_title;
if (!positionOf.has(change.employee_id)) continue;
const targetId = (change.payload as { target_position_id?: string }).target_position_id;
if (!targetId || !positionById.has(targetId)) continue;
positionOf.set(change.employee_id, targetId);
moved.add(change.employee_id);
}
// Second pass: a moved employee's manager follows from the *projected*
// org, not the one they left — and the lead of their new team may itself
// have moved in this same batch.
for (const r of resolved) {
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
const holders: OmHolder[] = [];
for (const [employeeId, positionId] of positionOf) {
const position = positionById.get(positionId)!;
holders.push({
employeeId,
positionId,
orgUnitId: position.org_unit_id,
isChief: position.is_chief,
absent: isAbsent(employeeById.get(employeeId)!),
});
}
// A manager who had not joined yet, or had already left, is not in this
// set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id));
const recordedManagerOf = (r: (typeof resolved)[number]) =>
r.managerId && presentIds.has(r.managerId) ? r.managerId : null;
const lines = resolveReportingLines(units, holders);
// While somebody is on a long-term absence their reports roll up to the
// next present level. Derived here rather than written to the database:
// the absent person stays formally in charge, and the stand-in is only a
// stand-in — which is why both ids travel to the UI.
const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
const acting = resolveActingManagers(
resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
);
const employees: OrgEmployee[] = resolved.map((r) => {
const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null };
const employees: OrgEmployee[] = lines.map((l) => {
const e = employeeById.get(l.employeeId)!;
const position = positionById.get(l.positionId)!;
return {
id: r.employee.id,
personnel_number: r.employee.personnel_number,
first_name: r.employee.first_name,
last_name: r.employee.last_name,
job_title: r.placement.job_title,
manager_id: actingManagerId,
formal_manager_id: coveredForId,
absent: absentIds.has(r.employee.id),
absence_type: r.employee.absence_type,
team_id: r.placement.team_id,
division_id: r.placement.division_id,
is_lead: r.placement.is_lead,
org_level: r.placement.org_level,
id: e.id,
personnel_number: e.personnel_number,
first_name: e.first_name,
last_name: e.last_name,
// Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person:
// bei einer projizierten Versetzung ist nur die erste schon richtig.
job_title: position.jobs.title,
manager_id: l.actingManagerId,
// Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die
// Oberfläche zweimal dieselbe Person an.
formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId,
absent: isAbsent(e),
absence_type: e.absence_type,
org_unit_id: l.orgUnitId,
is_chief: l.isChief,
position_id: l.positionId,
position_number: position.position_number,
};
});
const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
null
);
// Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene
// Tabelle neben der Organisation; hier sind sie schlicht das Komplement.
const besetzt = new Set(positionOf.values());
const vacancies: OrgVacancy[] = positions
.filter((p) => !besetzt.has(p.id))
.map((p) => ({
position_id: p.id,
position_number: p.position_number,
job_title: p.jobs.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
}));
return { employees, projectedCount: moved.size, historyStartsAt };
return { employees, vacancies, projectedCount: moved.size, historyStartsAt };
}