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.
193 lines
8.0 KiB
TypeScript
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])),
|
|
};
|
|
}
|