import type { Tx } from "./db"; import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers"; import { jsonArrayFrom, zeitstempel } from "./db/json"; import { todayIso } from "./format"; import { buildOrgMaps, orgMapsAbfragen } from "./org"; import { berichtslinienAbfrage, besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement"; import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "./positions"; // Was die Mitarbeiterakte liest — in vier Rundreisen statt in zwölf. // // Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der // Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion // hängt an einer Verbindung (lib/db/json.ts). // // Hier stand „bei rund 36 ms Umlaufzeit". Nachgemessen sind es 0,5 ms; die // Zahl der Rundreisen ist also nicht der Hebel, für den sie hier gehalten // wurde. Teuer war die Akte aus einem anderen Grund — 1,5 s, davon 1,4 s in // om_reporting_lines unter Zeilenschutz (siehe Migration 20260924220000). // Jetzt liegt die ganze Seite bei rund 100 ms. // // Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist // keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz // anders als der Treiber sonst, und `created_at` entscheidet in lib/history.ts // darüber, was später passiert ist. Ausgeschrieben ist sichtbar, welche Spalte // durch zeitstempel() geht — bei selectAll() wäre es unsichtbar falsch. export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) { // Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich der // Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. Eine // Person, die am 01.09. anfängt, hat heute keine laufende Besetzung — zu // heute gefragt lieferte om_reporting_lines() nichts, und die Akte // behauptete „Keine Führungskraft", obwohl das Team eine hat. const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(); if (!employee) return null; const asOf = orgAsOf(employee, today); const g = await tx .selectNoFrom((eb) => [ // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie // kommen aus om_reporting_lines(). // // **Einmal** gerufen, nicht zweimal. Hier standen zwei Teilabfragen mit // je einem Filter, in der Annahme, der Filter schränke die Funktion // ein. Das tut er nicht: wegen des `with recursive` in ihrem Körper // lässt sie sich nicht einbetten, sie rechnet jedes Mal alle Zeilen aus // und der Filter wirft sie danach weg. Auf dem Server gemessen: // // explain analyze select * from om_reporting_lines(current_date) // where employee_id = …; // → Rows Removed by Filter: 783, Execution Time: 69 ms // // Zwei Filter hiessen also zweimal dieselbe Rechnung. Jetzt wandern // einmal alle Zeilen herüber — ein paar hundert Kilobyte über eine // Verbindung im selben Netz — und die Auswahl trifft der Aufrufer. jsonArrayFrom(berichtslinienAbfrage(eb, asOf)).as("lines"), jsonArrayFrom( eb .selectFrom("employee_history") .select(["id", "employee_id", "event_date", "event_type", "description", "changes", "pending_id"]) .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) .where("employee_id", "=", id) .orderBy("event_date", "desc") .orderBy("created_at", "desc") ).as("history"), jsonArrayFrom( eb .selectFrom("employee_dependents") .select(["id", "employee_id", "first_name", "last_name", "relationship", "sv_nummer", "birth_date"]) .select((x) => zeitstempel(x.ref("created_at")).as("created_at")) .where("employee_id", "=", id) .orderBy("created_at") ).as("dependents"), jsonArrayFrom( eb .selectFrom("employee_notes") .select([ "id", "employee_id", "author_user_id", "author_name", "category", "note_text", "due_date", "done", "done_by", ]) .select((x) => [ zeitstempel(x.ref("created_at")).as("created_at"), zeitstempel(x.ref("done_at")).as("done_at"), ]) .where("employee_id", "=", id) .orderBy("created_at", "desc") ).as("notes"), ...orgMapsAbfragen(eb), jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"), // Die Kontierungen der Planstellen dieser Person. Welche gilt, hängt am // Stichtag — und der ergibt sich erst aus Eintritt und Austritt, steht // hier also schon fest. jsonArrayFrom( kontierungenAbfrage(eb).where("z.position_id", "in", (e) => e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id) ) ).as("costRows"), jsonArrayFrom( eb .selectFrom("onboarding_tasks") .select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"]) .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) .where("employee_id", "=", id) ).as("onboarding"), jsonArrayFrom( eb .selectFrom("offboarding_tasks") .select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"]) .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) .where("employee_id", "=", id) ).as("offboarding"), // Die offenen Planstellen bleiben bei heute: sie gehören zur // Organisation, nicht zu dieser Person. jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), ]) .executeTakeFirstOrThrow(); const orgMaps = buildOrgMaps(g.units as never, g.locations as never); const line = g.lines.find((l) => l.employee_id === id) ?? null; const reports = g.lines.filter((l) => l.acting_manager_id === id); const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null; // Namen für die beteiligten Personen in einem Zug: die Vertretung, die // formal zuständige Leitung und die direkten Berichte. const relatedIds = Array.from( new Set( [line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter( (x): x is string => Boolean(x) ) ) ); const [relatedRows, openPositions] = await Promise.all([ relatedIds.length ? tx .selectFrom("employees") .select(["id", "first_name", "last_name", "job_title", "status"]) .where("id", "in", relatedIds) .execute() : Promise.resolve([]), resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today), ]); return { employee, line, reports, history: g.history, dependents: g.dependents, notes: g.notes, orgMaps, placement, // Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt. kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null, onboarding: g.onboarding, offboarding: g.offboarding, openPositions, byId: new Map(relatedRows.map((e) => [e.id, e])), }; }