import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers"; import type { Tx } from "./db"; import { jsonArrayFrom } from "./db/json"; import { fmtName, todayIso } from "./format"; import { breadcrumbLabel, buildOrgMaps, orgMapsAbfragen, type OrgEb, type OrgMaps } from "./org"; // Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle // ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein // zweites Objekt daneben, das mit der Organisation synchron gehalten werden // müsste. export type OpenPositionResolved = { id: string; position_number: string; title: string; org_unit_id: string; is_chief: boolean; valid_from: string; valid_to: string | null; /** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */ org_unit_name: string; /** * Ob die Planstelle heute schon gilt. * * Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" — * sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides * gleich zu benennen wäre falsch. */ future: boolean; /** Wer die Stelle nach der Berichtslinie führen wird. */ managerName: string | null; orgLabel: string; /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ vacantSince: string; /** * Wessen Budget die Stelle belastet. * * Der Grund, warum die Kostenstelle an der Planstelle hängt und nicht an der * Person: hier gibt es keine Person, und trotzdem — gerade deshalb — ist die * Frage offen, was diese Stelle kostet und wer sie bezahlt. */ kostenstelle: { code: string; name: string } | null; }; /** * Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen * Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe * Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte. */ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null { if (!isChief) return orgUnitId; return maps.units.get(orgUnitId)?.parent_id ?? null; } export type OffeneStelle = { id: string; position_number: string; org_unit_id: string; is_chief: boolean; valid_from: string; valid_to: string | null; title: string; }; /** * Die unbesetzten Planstellen als *Teilabfrage*. * * Als eigene Funktion, damit eine Seite sie zusammen mit ihren übrigen * Lesevorgängen in einer Rundreise holen kann statt in einer eigenen — siehe * lib/db/json.ts. Wer nichts weiter zu holen hat, nimmt loadOpenPositions(). * * Unbesetzt heisst: keine Zuordnung, die noch gilt — **auch keine, die erst * beginnt.** * * Der Unterschied ist kein Feinschliff. Wer unterschrieben hat und am 24.09. * anfängt, belegt die Planstelle heute schon; sie steht nur noch nicht besetzt * da. Die frühere Fassung fragte „sitzt heute jemand darauf?" und listete * solche Stellen als offen — mit „seit 2 Tagen unbesetzt" daneben. Aus dieser * Liste speist sich auch die Auswahl im Einstellungsassistenten, also lud sie * dazu ein, dieselbe Stelle ein zweites Mal zu besetzen. Aufgefallen wäre das * erst am Teilindex der Datenbank, nach dem Gespräch mit der zweiten Person. * * Eine beendete Zuordnung (valid_to in der Vergangenheit) gibt die Stelle * dagegen wieder frei — deshalb bleibt die Bedingung auf valid_to. * * Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese Ansicht * existiert: eine Stelle, die zum Quartalswechsel entsteht, muss vorher * sichtbar und planbar sein. */ export function offeneStellenAbfrage(eb: OrgEb, asOf: string) { return eb .selectFrom("om_positions as p") .innerJoin("jobs as j", "j.id", "p.job_id") .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"]) .where((e) => e.or([e("p.valid_to", "is", null), e("p.valid_to", ">", asOf)])) // Als NOT EXISTS in der Datenbank statt als Filter über alle Planstellen // im Speicher. .where((e) => e.not( e.exists( e .selectFrom("position_assignments as a") .select("a.id") .whereRef("a.position_id", "=", "p.id") .where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)])) ) ) ) .orderBy("p.position_number"); } /** * Der zweite Teil: die beiden Nachschläge, die erst gestellt werden können, * wenn feststeht, welche Stellen offen sind — seit wann sie leer stehen, und * wer sie führen würde. Beide in einer Rundreise. */ export async function resolveOpenPositions( tx: Tx, orgMaps: OrgMaps, open: OffeneStelle[], asOf: string = todayIso() ): Promise { if (open.length === 0) return []; const positionIds = open.map((p) => p.id); const chiefUnitIds = Array.from( new Set( open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id)) ) ); const { ended, chiefs, costRows } = await tx .selectNoFrom((eb) => [ jsonArrayFrom( eb .selectFrom("position_assignments") .select(["position_id", "valid_to"]) .where("position_id", "in", positionIds) .where("valid_to", "is not", null) ).as("ended"), jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("costRows"), jsonArrayFrom( eb .selectFrom("om_positions as p") .innerJoin("position_assignments as a", "a.position_id", "p.id") .innerJoin("employees as e", "e.id", "a.employee_id") .select(["p.org_unit_id", "e.first_name", "e.last_name"]) .where("p.is_chief", "=", true) .where("p.valid_to", "is", null) .where("a.valid_to", "is", null) // Ohne Einheiten darf hier keine Liste stehen: `in ()` ist ein // Syntaxfehler, und `in ('')` bricht an der uuid-Umwandlung ab // statt nichts zu liefern. Dann also eine Bedingung, die nie zutrifft. .where((e) => (chiefUnitIds.length > 0 ? e("p.org_unit_id", "in", chiefUnitIds) : e.lit(false))) ).as("chiefs"), ]) .executeTakeFirstOrThrow(); const lastEndByPosition = new Map(); for (const e of ended) { const prev = lastEndByPosition.get(e.position_id); if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to); } const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, fmtName(c.first_name, c.last_name)])); return open.map((p) => { const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief); return { id: p.id, position_number: p.position_number, title: p.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, valid_from: p.valid_from, valid_to: p.valid_to, org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "", future: p.valid_from > asOf, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, // Nicht zum heutigen Tag, sondern zu dem, an dem die Stelle besteht: // eine künftige Planstelle wird erst mit ihrem Beginn kontiert, hätte // heute also keine Kostenstelle — obwohl gerade sie geplant wird. kostenstelle: kontierungAm(costRows as KontierungsZeile[], p.id, p.valid_from > asOf ? p.valid_from : asOf), }; }); } /** * Der bequeme Weg: Organisationsbaum und offene Stellen in einer Rundreise, * die beiden Nachschläge in einer zweiten. * * Zwei sind das Minimum — was in der zweiten steht, hängt vom Ergebnis der * ersten ab. */ export async function loadOpenPositions(tx: Tx): Promise { const asOf = todayIso(); const { units, locations, open } = await tx .selectNoFrom((eb) => [ ...orgMapsAbfragen(eb), jsonArrayFrom(offeneStellenAbfrage(eb, asOf)).as("open"), ]) .executeTakeFirstOrThrow(); return resolveOpenPositions(tx, buildOrgMaps(units as never, locations as never), open as OffeneStelle[], asOf); }