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:
@@ -1,4 +1,6 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
||||
import { loadPlacements } from "./placement";
|
||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
@@ -7,6 +9,7 @@ import type { Database, EmploymentType, HistoryEventType } from "./supabase/type
|
||||
// what "the current view" means — same filters, same stichtag/event-window
|
||||
// rules.
|
||||
export type ReportFilters = {
|
||||
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
@@ -16,33 +19,49 @@ export type ReportFilters = {
|
||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
||||
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
||||
|
||||
/**
|
||||
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
|
||||
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
|
||||
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
|
||||
*/
|
||||
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
|
||||
const divisionName = new Map<string, string>();
|
||||
const departmentName = new Map<string, string>();
|
||||
const teamName = new Map<string, string>();
|
||||
|
||||
for (const unit of orgMaps.unitList) {
|
||||
for (const a of ancestorsOf(orgMaps, unit.id)) {
|
||||
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
|
||||
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
|
||||
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
||||
}
|
||||
|
||||
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
||||
lookups: OrgLookups;
|
||||
orgMaps: OrgMaps;
|
||||
divisions: { id: string; name: string }[];
|
||||
locations: { id: string; name: string }[];
|
||||
}> {
|
||||
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||
supabase.from("divisions").select("id, name").order("name"),
|
||||
supabase.from("departments").select("id, name"),
|
||||
supabase.from("teams").select("id, name, department_id"),
|
||||
supabase.from("locations").select("id, name").order("name"),
|
||||
]);
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
||||
|
||||
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
|
||||
return {
|
||||
lookups: {
|
||||
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
|
||||
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
|
||||
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
|
||||
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
|
||||
},
|
||||
divisions: divisions ?? [],
|
||||
locations: locations ?? [],
|
||||
lookups: lookupsFromOrgMaps(orgMaps, locations),
|
||||
orgMaps,
|
||||
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
|
||||
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
|
||||
// ganzen Teilbaum.
|
||||
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
|
||||
locations,
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
||||
"id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
||||
|
||||
// employee_id -> number of employee_dependents rows. Selects only the FK
|
||||
// column (no dependent PII needed) since only per-employee counts feed the
|
||||
@@ -56,58 +75,73 @@ export async function loadDependentsCounts(supabase: SupabaseClient<Database>):
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
|
||||
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
|
||||
// division/team/location still reflect the employee's *current* assignment.
|
||||
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
||||
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||
const asOf = filters.asOf || todayIso();
|
||||
|
||||
function snapshotQuery() {
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
return query;
|
||||
}
|
||||
|
||||
const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
|
||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
||||
fetchAllRows(snapshotQuery),
|
||||
loadDependentsCounts(supabase),
|
||||
loadPlacements(supabase, { asOf }),
|
||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: e.job_title,
|
||||
division_id: e.division_id,
|
||||
team_id: e.team_id,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
contract_type: e.contract_type,
|
||||
entry_date: e.entry_date,
|
||||
exit_date: e.exit_date,
|
||||
weekly_hours: e.weekly_hours,
|
||||
source: e.source,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
worker_type: e.worker_type,
|
||||
collective_agreement: e.collective_agreement,
|
||||
work_days: e.work_days,
|
||||
is_betriebsrat: e.is_betriebsrat,
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
}));
|
||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
||||
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
|
||||
// Bereich selbst nur die Bereichsleitung sitzt.
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = [];
|
||||
for (const e of data) {
|
||||
const placement = placements.get(e.id);
|
||||
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
|
||||
// sitzt aber auf keiner Planstelle mehr.
|
||||
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
||||
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
|
||||
|
||||
withDerivedStatus.push({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: placement?.jobTitle ?? e.job_title,
|
||||
org_unit_id: orgUnitId,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
contract_type: e.contract_type,
|
||||
entry_date: e.entry_date,
|
||||
exit_date: e.exit_date,
|
||||
weekly_hours: e.weekly_hours,
|
||||
source: e.source,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
worker_type: e.worker_type,
|
||||
collective_agreement: e.collective_agreement,
|
||||
work_days: e.work_days,
|
||||
is_betriebsrat: e.is_betriebsrat,
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
||||
}
|
||||
|
||||
// Ereignisse: employee_history has no division_id/team_id of its own, so
|
||||
// this joins in the affected employee's *current* org placement (two plain
|
||||
// queries, merged in JS — the hand-written Database type has no relational
|
||||
// embedding metadata for a single nested-select query).
|
||||
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
|
||||
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
|
||||
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
|
||||
// Team stand, in das die Person nie versetzt worden war.
|
||||
//
|
||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||
@@ -125,25 +159,49 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
|
||||
return query;
|
||||
}
|
||||
|
||||
const [history, employees] = await Promise.all([
|
||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||
fetchAllRows(historyQuery),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")),
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("position_assignments")
|
||||
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
|
||||
.order("employee_id")
|
||||
),
|
||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
||||
for (const a of assignments as unknown as {
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
om_positions: { org_unit_id: string };
|
||||
}[]) {
|
||||
const list = spans.get(a.employee_id) ?? [];
|
||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
|
||||
spans.set(a.employee_id, list);
|
||||
}
|
||||
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
|
||||
const events: ReportEvent[] = [];
|
||||
for (const h of history) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) continue;
|
||||
if (filters.location && emp.location_id !== filters.location) continue;
|
||||
|
||||
const unitId =
|
||||
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
|
||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
||||
|
||||
events.push({
|
||||
employee_id: emp.id,
|
||||
first_name: emp.first_name,
|
||||
last_name: emp.last_name,
|
||||
job_title: emp.job_title,
|
||||
division_id: emp.division_id,
|
||||
team_id: emp.team_id,
|
||||
org_unit_id: unitId,
|
||||
location_id: emp.location_id,
|
||||
event_date: h.event_date,
|
||||
event_type: h.event_type,
|
||||
|
||||
Reference in New Issue
Block a user