Files
alpenwerk-hr/lib/positions.ts
Andrei Laas 2eda038c2e
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite
Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte
und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends
zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre
Taetigkeit.

Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte
eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben
Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst,
entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit
und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung
gehoert und nicht ueber die Stelle.

Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788
Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig
Einheiten zu laden, was man fuer eine braucht.

EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern
nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der
Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
2026-09-28 21:52:01 +02:00

222 lines
8.7 KiB
TypeScript

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;
};
/**
* Was der Änderungsdialog von einer Planstelle braucht — und nicht mehr.
*
* Er verlangte bisher eine `OpenPositionResolved`, also die *unbesetzte*
* Planstelle samt „seit wann offen" und „wer würde sie führen". Damit liess er
* sich nur dort öffnen, wo diese Angaben entstehen: auf der Seite Positionen,
* die ausschliesslich unbesetzte Stellen zeigt. Die Kontierung einer besetzten
* Planstelle war deshalb nirgends änderbar — bei Manner betraf das 784 von 788.
*/
export type PlanstelleZumAendern = Pick<
OpenPositionResolved,
"id" | "position_number" | "title" | "org_unit_id" | "is_chief" | "valid_from" | "valid_to" | "kostenstelle"
>;
/**
* 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<OpenPositionResolved[]> {
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<string, string>();
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<OpenPositionResolved[]> {
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);
}