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>
This commit is contained in:
107
lib/cost-centers.ts
Normal file
107
lib/cost-centers.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import { todayIso } from "./format";
|
||||
import type { OrgEb } from "./org";
|
||||
|
||||
// Die Kostenstelle einer Planstelle — zum Stichtag, nicht „aktuell".
|
||||
//
|
||||
// Sie hängt an der Planstelle, nicht an der Person: der Sitz kostet Geld, auch
|
||||
// wenn niemand darauf sitzt. Genau das ist die Frage bei einer Vakanz.
|
||||
//
|
||||
// Und sie hat einen Zeitraum, weil eine Umkontierung ein Ereignis mit Stichtag
|
||||
// ist. Wer nach den Kosten des Vorjahres fragt, muss die Kostenstelle von
|
||||
// damals bekommen — sonst verändert jede Umkontierung rückwirkend jede alte
|
||||
// Auswertung, und das merkt niemand.
|
||||
|
||||
export type Kostenstelle = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type KontierungsZeile = {
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Kontierungen als *Teilabfrage* — zum Einhängen in die eine Abfrage, die
|
||||
* eine Seite ohnehin stellt (lib/db/json.ts).
|
||||
*
|
||||
* Ohne `positionIds` alle. Gefiltert wird nicht nach Stichtag: die Auswahl
|
||||
* trifft `kontierungZum` im Speicher, damit dieselben Zeilen für mehrere
|
||||
* Stichtage reichen und die Abfrage eine bleibt.
|
||||
*/
|
||||
export function kontierungenAbfrage(eb: OrgEb, positionIds?: string[]) {
|
||||
const q = eb
|
||||
.selectFrom("position_cost_centers as z")
|
||||
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
|
||||
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
|
||||
.orderBy("z.position_id")
|
||||
.orderBy("z.valid_from");
|
||||
|
||||
return positionIds ? q.where("z.position_id", "in", positionIds) : q;
|
||||
}
|
||||
|
||||
/** Alle Kostenstellen zur Auswahl — abgelaufene bleiben draussen. */
|
||||
export function kostenstellenAbfrage(eb: OrgEb, asOf: string = todayIso()) {
|
||||
return eb
|
||||
.selectFrom("cost_centers")
|
||||
.select(["id", "code", "name"])
|
||||
.where((e) => e.or([e("valid_to", "is", null), e("valid_to", ">", asOf)]))
|
||||
.orderBy("code");
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Kostenstelle je Planstelle am Stichtag galt.
|
||||
*
|
||||
* Halboffen [valid_from, valid_to): der letzte Tag gehört schon zur nächsten
|
||||
* Kontierung. Dieselbe Regel wie bei den Besetzungen — eine zweite Auslegung
|
||||
* desselben Zeitraummodells wäre der sichere Weg zu zwei Antworten auf
|
||||
* dieselbe Frage.
|
||||
*/
|
||||
export function kontierungZum(rows: KontierungsZeile[], asOf: string): Map<string, Kostenstelle> {
|
||||
const out = new Map<string, Kostenstelle>();
|
||||
for (const r of rows) {
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
out.set(r.position_id, { id: r.cost_center_id, code: r.code, name: r.name });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie kontierungZum, aber für eine einzelne Planstelle und einen Stichtag,
|
||||
* der nicht heute sein muss.
|
||||
*
|
||||
* Nötig für Planstellen, die erst entstehen: ihre Kontierung beginnt mit
|
||||
* ihnen. Zu heute gefragt gäbe es keine — und die Vakanzliste, die künftige
|
||||
* Stellen bewusst zeigt, stünde für genau diese ohne Kostenstelle da. Die
|
||||
* Frage lautet dort nicht „wer zahlt heute", sondern „wer zahlt, wenn es
|
||||
* losgeht".
|
||||
*/
|
||||
export function kontierungAm(rows: KontierungsZeile[], positionId: string, asOf: string): Kostenstelle | null {
|
||||
for (const r of rows) {
|
||||
if (r.position_id !== positionId) continue;
|
||||
if (r.valid_from > asOf) continue;
|
||||
if (r.valid_to !== null && r.valid_to <= asOf) continue;
|
||||
return { id: r.cost_center_id, code: r.code, name: r.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der bequeme Weg für Aufrufer ohne eigene Abfrage. */
|
||||
export async function loadKontierungen(
|
||||
tx: Tx,
|
||||
{ asOf, positionIds }: { asOf: string; positionIds?: string[] }
|
||||
): Promise<Map<string, Kostenstelle>> {
|
||||
if (positionIds?.length === 0) return new Map();
|
||||
const { rows } = await tx
|
||||
.selectNoFrom((eb) => [jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("rows")])
|
||||
.executeTakeFirstOrThrow();
|
||||
return kontierungZum(rows as KontierungsZeile[], asOf);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -80,6 +81,14 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
|
||||
...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"),
|
||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||
// Organisation, nicht zu dieser Person.
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
@@ -88,6 +97,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
|
||||
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
|
||||
const line = g.ownLines[0] ?? null;
|
||||
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.
|
||||
@@ -118,7 +128,9 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
dependents: g.dependents,
|
||||
notes: g.notes,
|
||||
orgMaps,
|
||||
placement: pickPlacements(g.placementRows as never, asOf).get(id) ?? null,
|
||||
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,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
|
||||
import type { Tx } from "./db";
|
||||
import { jsonArrayFrom } from "./db/json";
|
||||
import { fmtName, todayIso } from "./format";
|
||||
@@ -31,6 +32,14 @@ export type OpenPositionResolved = {
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -120,7 +129,7 @@ export async function resolveOpenPositions(
|
||||
)
|
||||
);
|
||||
|
||||
const { ended, chiefs } = await tx
|
||||
const { ended, chiefs, costRows } = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -129,6 +138,7 @@ export async function resolveOpenPositions(
|
||||
.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")
|
||||
@@ -168,6 +178,10 @@ export async function resolveOpenPositions(
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +479,49 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||
};
|
||||
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
|
||||
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
|
||||
cost_centers: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
org_unit_id: string | null;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
org_unit_id?: string | null;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["cost_centers"]["Insert"]>;
|
||||
};
|
||||
// A011: Planstelle kontiert auf Kostenstelle, zeitabhängig.
|
||||
position_cost_centers: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_id: string;
|
||||
cost_center_id: string;
|
||||
valid_from: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["position_cost_centers"]["Insert"]>;
|
||||
};
|
||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
||||
position_assignments: {
|
||||
Row: {
|
||||
@@ -541,6 +584,7 @@ export type Database = {
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
|
||||
Reference in New Issue
Block a user