Files
alpenwerk-hr/lib/employee-detail-data.ts
Andrei Laas c8f9ae8047
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has started running
Vorübergehend messen, woher die anderthalb Sekunden der Akte kommen
Die Akte braucht beim Oeffnen rund 1,5 s. Die Datenbank ist es dem
Anschein nach nicht: eine Rundreise kostet 0,5 ms (nicht 36, wie der
Kommentar hier behauptete), und om_reporting_lines misst 60 ms. Damit
laegen unter zehn Prozent der Zeit in den Abfragen.

Statt weiter zu raten -- heute schon zweimal danebengelegen -- schreibt
loadEmployeeDetail eine Zeile ins Protokoll des Containers: Zeit fuer die
Person, fuer die Hauptabfrage, fuer die Nachschlaege, insgesamt, und wie
viele Berichtslinien uebertragen wurden.

Wieder entfernen, sobald die Ursache feststeht.
2026-09-24 11:52:36 +02:00

193 lines
8.0 KiB
TypeScript

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). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
//
// 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.
// ── Vorübergehende Messung ────────────────────────────────────────
//
// Die Akte braucht beim Öffnen rund anderthalb Sekunden, und es ist unklar,
// wovon. Die Datenbank ist es dem Anschein nach nicht: eine Rundreise kostet
// 0,5 ms, om_reporting_lines 60 ms. Statt weiter zu raten, misst diese Zeile
// die drei Abschnitte und schreibt eine Zeile ins Protokoll des Containers.
//
// **Wieder entfernen**, sobald die Ursache feststeht.
function messen(id: string, marken: [string, number][]) {
const teile = marken.map(([name, ms]) => `${name} ${Math.round(ms)}ms`).join(", ");
console.info(`[Akte ${id.slice(0, 8)}] ${teile}`);
}
export async function loadEmployeeDetail(tx: Tx, id: string, today: string = todayIso()) {
const t0 = performance.now();
// 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 t1 = performance.now();
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 t2 = performance.now();
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),
]);
const t3 = performance.now();
messen(id, [
["person", t1 - t0],
["hauptabfrage", t2 - t1],
["nachschlag", t3 - t2],
["gesamt", t3 - t0],
["zeilen", g.lines.length],
]);
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])),
};
}