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.
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
|
import { todayIso } from "./format";
|
|
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
|
import { fetchAllRows } from "./supabase/query";
|
|
import type { Database } from "./supabase/types";
|
|
|
|
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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.
|
|
|
|
/** Änderungsarten, die jemanden in der Organisation verschieben. */
|
|
const PLACEMENT_CHANGES = ["transfer"] as const;
|
|
|
|
export type OrgAsOfResult = {
|
|
employees: OrgEmployee[];
|
|
vacancies: OrgVacancy[];
|
|
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
|
|
projectedCount: number;
|
|
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */
|
|
historyStartsAt: string | null;
|
|
};
|
|
|
|
type EmployeeRow = {
|
|
id: string;
|
|
personnel_number: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
job_title: string;
|
|
karenz_start_date: string | null;
|
|
karenz_return_date: string | null;
|
|
absence_type: string | null;
|
|
};
|
|
|
|
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> };
|
|
|
|
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
|
const today = todayIso();
|
|
|
|
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
|
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")),
|
|
fetchAllRows(() =>
|
|
supabase
|
|
.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("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, 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,
|
|
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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die
|
|
* Regeln direkt prüfbar sind.
|
|
*/
|
|
export function resolveOrgSnapshot({
|
|
asOf,
|
|
units,
|
|
positions,
|
|
assignments,
|
|
employees: allEmployees,
|
|
pending,
|
|
historyStartsAt,
|
|
}: {
|
|
asOf: string;
|
|
units: OmUnit[];
|
|
positions: PositionRow[];
|
|
assignments: AssignmentRow[];
|
|
employees: EmployeeRow[];
|
|
pending: PendingRow[];
|
|
historyStartsAt: string | null;
|
|
}): OrgAsOfResult {
|
|
const positionById = new Map(positions.map((p) => [p.id, p]));
|
|
const employeeById = new Map(allEmployees.map((e) => [e.id, e]));
|
|
|
|
// 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 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);
|
|
}
|
|
|
|
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
|
|
// Versetzung gewinnt.
|
|
const moved = new Set<string>();
|
|
for (const change of pending) {
|
|
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);
|
|
}
|
|
|
|
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)!),
|
|
});
|
|
}
|
|
|
|
const lines = resolveReportingLines(units, holders);
|
|
|
|
const employees: OrgEmployee[] = lines.map((l) => {
|
|
const e = employeeById.get(l.employeeId)!;
|
|
const position = positionById.get(l.positionId)!;
|
|
return {
|
|
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,
|
|
};
|
|
});
|
|
|
|
// 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, vacancies, projectedCount: moved.size, historyStartsAt };
|
|
}
|