Files
alpenwerk-hr/lib/positions.ts
Maximilian Stubhan e30923e781 Give a position a cost centre
There was none anywhere in the model, so no personnel-cost figure could
be produced at all, and an open position could not say whose budget it
would charge — which is the first question asked about a vacancy.

It hangs on the position, not on the person: the seat costs money even
when nobody sits on it. That is exactly the vacancy case. And not on the
org unit either, although it usually follows from one — a single seat
can be charged elsewhere (project, shared function) without the unit
moving.

As its own dated assignment table rather than a column, because
reassigning is an event with a date. Last year's costs have to stay
where they were incurred; as a column, every change would silently
rewrite every past report. Half-open [valid_from, valid_to), like
position_assignments and om_positions — in SAP OM this is A011.

25 cost centres seeded from the org tree: one per company, division and
department, with teams charging to their department, because a team is a
span of control and not a budget. All 823 positions were assigned from
their own start date, none left over. The number is the first five digits
of the org number, so it can be traced rather than looked up.

Reassignment refuses three things, each checked: the same cost centre
again, a switch on the day the current one started (that period would
never have been in force, and the range constraint says so), and a date
before the position exists.

Verified against the real data, which turned up a defect worth keeping:
a position that starts in the future is charged only from its start, so
asked about today it had no cost centre — and future positions are
exactly what the vacancy list is for. It is now read at the position's
own start date.

Two audit entries from the probe could not be deleted through the
application (the log has no delete policy — correctly), so I removed
them with the admin connection.

Still open, and the reason this is only the first of the three fields I
proposed: location and planned FTE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:57:09 +02:00

208 lines
8.0 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;
};
/**
* 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);
}