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:
2026-08-16 19:57:09 +02:00
parent 3a4f44318c
commit e30923e781
13 changed files with 582 additions and 25 deletions

View File

@@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc";
const POSITION_PATHS = ["/positions", "/orgchart", "/"]; const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc( async function callRpc(
fn: "create_position" | "update_position" | "delete_position", fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center",
payload: Record<string, unknown>, payload: Record<string, unknown>,
revalidate: string[] revalidate: string[]
): Promise<ActionResult> { ): Promise<ActionResult> {
@@ -49,3 +49,19 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
} }
/**
* Kontiert eine Planstelle auf eine andere Kostenstelle — zum Stichtag.
*
* Kein Feld im Änderungsdialog, sondern ein eigener Vorgang, weil es einer
* ist: die laufende Kontierung wird beendet, die neue beginnt. Die alte
* einfach zu überschreiben hiesse, die Kosten der Vergangenheit nachträglich
* anderswohin zu buchen.
*/
export async function setPositionCostCenter(payload: {
position_id: string;
cost_center_id: string;
valid_from: string;
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}

View File

@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today)); const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
if (!data) notFound(); if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data; const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data;
return ( return (
<EmployeeDetail <EmployeeDetail
@@ -31,6 +31,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
} }
} }
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)} breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
kostenstelle={kostenstelle}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null} manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person // Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da. // zweimal da.

View File

@@ -1,22 +1,39 @@
import type { UnitOption } from "@/components/positions/CreatePositionModal"; import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetweenIso } from "@/lib/format";
import { loadOrgMaps } from "@/lib/org";
import { loadOpenPositions } from "@/lib/positions";
import { currentUserId } from "@/lib/auth/session"; import { currentUserId } from "@/lib/auth/session";
import { kostenstellenAbfrage } from "@/lib/cost-centers";
import { withUser } from "@/lib/db"; import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { daysBetweenIso, todayIso } from "@/lib/format";
import { buildOrgMaps, orgMapsAbfragen } from "@/lib/org";
import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from "@/lib/positions";
export default async function PositionsPage() { export default async function PositionsPage() {
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => { const today = todayIso();
const [openPositions, orgMaps, chiefRows] = await Promise.all([
loadOpenPositions(tx), const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => {
loadOrgMaps(tx), // Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts).
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der const g = await tx
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine .selectNoFrom((eb) => [
// Fehlermeldung nach dem Absenden. ...orgMapsAbfragen(eb),
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(), jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
]); jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"),
return { openPositions, orgMaps, chiefRows }; // Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden.
jsonArrayFrom(
eb.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null)
).as("chiefRows"),
])
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
return {
orgMaps,
chiefRows: g.chiefRows,
kostenstellen: g.kostenstellen,
openPositions: await resolveOpenPositions(tx, orgMaps, g.open as OffeneStelle[], today),
};
}); });
const withChief = new Set(chiefRows.map((r) => r.org_unit_id)); const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
@@ -30,5 +47,5 @@ export default async function PositionsPage() {
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) })); const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />; return <PositionsPageClient openPositions={openPositionsWithDays} units={units} kostenstellen={kostenstellen} />;
} }

View File

@@ -34,6 +34,8 @@ type EmployeeDetailProps = {
employee: EmployeeRow; employee: EmployeeRow;
placement: PlacementInfo | null; placement: PlacementInfo | null;
breadcrumb: string; breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
manager: MiniEmployee | null; manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */ /** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null; formalManager: MiniEmployee | null;
@@ -49,7 +51,7 @@ type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "re
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const; const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) { export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten"); const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null); const [panel, setPanel] = useState<PanelType>(null);
@@ -159,6 +161,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
formalManager={formalManager} formalManager={formalManager}
directReports={directReports} directReports={directReports}
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
kostenstelle={kostenstelle}
/> />
)} )}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />} {tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}

View File

@@ -13,15 +13,39 @@ type OrganisationTabProps = {
formalManager: MiniEmployee | null; formalManager: MiniEmployee | null;
directReports: MiniEmployee[]; directReports: MiniEmployee[];
breadcrumb: string; breadcrumb: string;
/**
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
*/
kostenstelle: { code: string; name: string } | null;
}; };
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) { export function OrganisationTab({
employeeId,
manager,
formalManager,
directReports,
breadcrumb,
kostenstelle,
}: OrganisationTabProps) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p> <p className="mt-1 text-sm text-ink">{breadcrumb}</p>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
<p className="mt-1 text-sm text-ink">
{kostenstelle ? (
<>
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
<span className="text-ink-body"> · {kostenstelle.name}</span>
</>
) : (
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
)}
</p>
</div> </div>
{/* ?focus= drives the same highlight/auto-expand path the org chart {/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on search already uses, so the person is unfolded and centred on

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { updatePosition } from "@/actions/positions"; import { setPositionCostCenter, updatePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field"; import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
@@ -29,10 +29,15 @@ import type { UnitOption } from "./CreatePositionModal";
export function EditPositionModal({ export function EditPositionModal({
position, position,
units, units,
kostenstellen,
heute,
onClose, onClose,
}: { }: {
position: OpenPositionResolved; position: OpenPositionResolved;
units: UnitOption[]; units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
heute: string;
onClose: () => void; onClose: () => void;
}) { }) {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -42,8 +47,18 @@ export function EditPositionModal({
const [isChief, setIsChief] = useState(position.is_chief); const [isChief, setIsChief] = useState(position.is_chief);
const [validFrom, setValidFrom] = useState(position.valid_from); const [validFrom, setValidFrom] = useState(position.valid_from);
const [validTo, setValidTo] = useState(position.valid_to ?? ""); const [validTo, setValidTo] = useState(position.valid_to ?? "");
// Die Kontierung ist ein eigener Vorgang mit eigenem Stichtag, kein Feld
// unter anderen: die laufende wird beendet, die neue beginnt. Deshalb steht
// hier ein Datum daneben und kein stilles Überschreiben.
const [kostenstelleId, setKostenstelleId] = useState(
kostenstellen.find((k) => k.code === position.kostenstelle?.code)?.id ?? ""
);
const [kostenstelleAb, setKostenstelleAb] = useState(heute);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const kostenstelleGeaendert =
kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code;
const unit = units.find((u) => u.id === orgUnitId); const unit = units.find((u) => u.id === orgUnitId);
// Die eigene Leitung zählt nicht als Hindernis für sich selbst. // Die eigene Leitung zählt nicht als Hindernis für sich selbst.
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id); const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
@@ -62,9 +77,26 @@ export function EditPositionModal({
valid_from: validFrom, valid_from: validFrom,
valid_to: validTo || null, valid_to: validTo || null,
}); });
if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
// fehl, steht die übrige Änderung trotzdem — und die Meldung sagt, was
// offen blieb, statt beides stillschweigend zu verwerfen.
const kosten = await setPositionCostCenter({
position_id: position.id,
cost_center_id: kostenstelleId,
valid_from: kostenstelleAb,
});
if (!kosten.success) {
setPending(false);
showToast(kosten.error ?? "Die Kostenstelle konnte nicht geändert werden.", "error");
router.refresh();
return;
}
}
setPending(false); setPending(false);
if (result.success) { if (result.success) {
showToast("Planstelle geändert."); showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
router.refresh(); router.refresh();
onClose(); onClose();
} else { } else {
@@ -110,6 +142,30 @@ export function EditPositionModal({
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} /> <TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
</div> </div>
<SelectField
label="Kostenstelle"
value={kostenstelleId}
onChange={setKostenstelleId}
placeholder="Keine"
options={kostenstellen.map((k) => ({ value: k.id, label: `${k.code} · ${k.name}` }))}
/>
{kostenstelleGeaendert && (
<div className="rounded border border-border bg-surface px-3 py-2">
<TextField
label="Umkontieren ab"
required
type="date"
value={kostenstelleAb}
onChange={setKostenstelleAb}
min={position.valid_from}
/>
<p className="mt-1.5 text-xs text-ink-muted">
Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es
gebucht wurde.
</p>
</div>
)}
<label className="flex items-start gap-2 text-sm text-ink-body"> <label className="flex items-start gap-2 text-sm text-ink-body">
<input <input
type="checkbox" type="checkbox"

View File

@@ -6,7 +6,7 @@ import { useState } from "react";
import { deletePosition } from "@/actions/positions"; import { deletePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal"; import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
import { EditPositionModal } from "./EditPositionModal"; import { EditPositionModal } from "./EditPositionModal";
@@ -16,9 +16,10 @@ type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = { type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[]; openPositions: OpenPositionWithDays[];
units: UnitOption[]; units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
}; };
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) { export function PositionsPageClient({ openPositions, units, kostenstellen }: PositionsPageClientProps) {
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -66,6 +67,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
</span> </span>
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>} {p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>} {p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
Vakanz zuerst kommt. */}
{p.kostenstelle && (
<span className="text-xs text-ink-muted">
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
{p.kostenstelle.name}
</span>
)}
</button> </button>
<Button <Button
@@ -121,7 +130,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
baut — sonst blieben beim zweiten Öffnen die Werte des ersten baut — sonst blieben beim zweiten Öffnen die Werte des ersten
stehen. */} stehen. */}
{editing && ( {editing && (
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} /> <EditPositionModal
key={editing.id}
position={editing}
units={units}
kostenstellen={kostenstellen}
heute={todayIso()}
onClose={() => setEditing(null)}
/>
)} )}
</div> </div>
); );

107
lib/cost-centers.ts Normal file
View 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);
}

View File

@@ -1,4 +1,5 @@
import type { Tx } from "./db"; import type { Tx } from "./db";
import { kontierungZum, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import { jsonArrayFrom, zeitstempel } from "./db/json"; import { jsonArrayFrom, zeitstempel } from "./db/json";
import { todayIso } from "./format"; import { todayIso } from "./format";
import { buildOrgMaps, orgMapsAbfragen } from "./org"; import { buildOrgMaps, orgMapsAbfragen } from "./org";
@@ -80,6 +81,14 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
...orgMapsAbfragen(eb), ...orgMapsAbfragen(eb),
jsonArrayFrom(besetzungenAbfrage(eb, [id])).as("placementRows"), 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 // Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person. // Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), 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 orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null; 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 // Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte. // 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, dependents: g.dependents,
notes: g.notes, notes: g.notes,
orgMaps, 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, openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])), byId: new Map(relatedRows.map((e) => [e.id, e])),
}; };

View File

@@ -1,3 +1,4 @@
import { kontierungAm, kontierungenAbfrage, type KontierungsZeile } from "./cost-centers";
import type { Tx } from "./db"; import type { Tx } from "./db";
import { jsonArrayFrom } from "./db/json"; import { jsonArrayFrom } from "./db/json";
import { fmtName, todayIso } from "./format"; import { fmtName, todayIso } from "./format";
@@ -31,6 +32,14 @@ export type OpenPositionResolved = {
orgLabel: string; orgLabel: string;
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
vacantSince: string; 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) => [ .selectNoFrom((eb) => [
jsonArrayFrom( jsonArrayFrom(
eb eb
@@ -129,6 +138,7 @@ export async function resolveOpenPositions(
.where("position_id", "in", positionIds) .where("position_id", "in", positionIds)
.where("valid_to", "is not", null) .where("valid_to", "is not", null)
).as("ended"), ).as("ended"),
jsonArrayFrom(kontierungenAbfrage(eb, positionIds)).as("costRows"),
jsonArrayFrom( jsonArrayFrom(
eb eb
.selectFrom("om_positions as p") .selectFrom("om_positions as p")
@@ -168,6 +178,10 @@ export async function resolveOpenPositions(
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null, managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id), orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from, 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),
}; };
}); });
} }

View File

@@ -479,6 +479,49 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>; 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. // A008: Person besetzt Planstelle, zeitabhängig.
position_assignments: { position_assignments: {
Row: { Row: {
@@ -541,6 +584,7 @@ export type Database = {
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string }; create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void }; 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 }; delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number }; apply_due_pending_changes: { Args: Record<string, never>; Returns: number };

View File

@@ -0,0 +1,171 @@
-- Kostenstellen
--
-- Die Kostenstelle hing bisher nirgends. Damit war keine einzige Auswertung
-- nach Personalkosten möglich, und eine offene Planstelle konnte nicht sagen,
-- wessen Budget sie belastet.
--
-- 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 hängt nicht an der Organisationseinheit, obwohl sie sich meistens
-- daraus ergibt: eine einzelne Stelle kann auf eine fremde Kostenstelle
-- kontiert sein (Projekt, Umlage, geteilte Funktion), ohne dass die Einheit
-- wechselt.
--
-- Als eigene Zuordnungstabelle mit Zeitraum, nicht als Spalte. Eine Umkontier-
-- ung ist ein Ereignis mit Stichtag: die Kosten des Vorjahres müssen dort
-- bleiben, wo sie angefallen sind. Als Spalte würde jede Änderung rückwirkend
-- jede alte Auswertung verändern — und das merkt niemand.
--
-- Halboffene Zeiträume [valid_from, valid_to), wie bei position_assignments
-- und om_positions. In SAP OM ist das die Verknüpfung A011 (Kontierung).
create table if not exists cost_centers (
id uuid primary key default gen_random_uuid(),
code text not null unique,
name text not null,
-- Die verantwortliche Einheit. Nur ein Hinweis, keine Ableitung: die
-- Zuordnung einer Planstelle bleibt die der Planstelle.
org_unit_id uuid references org_units(id) on delete set null,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_cost_center_range check (valid_to is null or valid_to > valid_from)
);
create table if not exists position_cost_centers (
id uuid primary key default gen_random_uuid(),
position_id uuid not null references om_positions(id) on delete cascade,
cost_center_id uuid not null references cost_centers(id),
valid_from date not null,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_pcc_range check (valid_to is null or valid_to > valid_from)
);
create index if not exists position_cost_centers_position_id_idx on position_cost_centers (position_id);
create index if not exists position_cost_centers_cost_center_id_idx on position_cost_centers (cost_center_id);
-- Eine Planstelle wird zu einem Zeitpunkt auf genau eine Kostenstelle
-- kontiert. Eine Kostenteilung wäre etwas anderes und bräuchte einen Anteil.
create unique index if not exists position_cost_centers_one_current
on position_cost_centers (position_id) where valid_to is null;
-- RLS schaltet der Ereignis-Trigger ensure_rls automatisch ein; ohne Policy
-- käme aus beiden Tabellen nichts zurück.
drop policy if exists cost_centers_hr_all on cost_centers;
create policy cost_centers_hr_all on cost_centers for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists position_cost_centers_hr_all on position_cost_centers;
create policy position_cost_centers_hr_all on position_cost_centers
for all using (is_hr_user()) with check (is_hr_user());
-- ── Bestand: Kostenstellen anlegen und alle Planstellen kontieren ──────
--
-- Eine Kostenstelle je Gesellschaft, Bereich und Abteilung — Teams tragen
-- keine eigene, sie kontieren auf ihre Abteilung. Das ist die übliche Tiefe:
-- ein Team ist eine Führungsspanne, kein Budgettopf.
--
-- Die Nummer wird aus der Organisationsnummer abgeleitet (die ersten fünf
-- Stellen), damit sie nachvollziehbar ist und nicht geraten wirkt.
insert into cost_centers (code, name, org_unit_id, valid_from)
select left(o.org_number, 5), o.name, o.id, o.valid_from
from org_units o
where o.unit_type in ('Gesellschaft', 'Bereich', 'Abteilung')
and not exists (select 1 from cost_centers k where k.org_unit_id = o.id)
on conflict (code) do nothing;
-- Jede Planstelle auf die Kostenstelle der nächsten Einheit über ihr, die
-- eine hat — sie selbst zuerst.
with recursive kette as (
select p.id as position_id, p.org_unit_id as unit_id, p.valid_from, 0 as tiefe
from om_positions p
where not exists (select 1 from position_cost_centers z where z.position_id = p.id)
union all
select k.position_id, o.parent_id, k.valid_from, k.tiefe + 1
from kette k
join org_units o on o.id = k.unit_id
where o.parent_id is not null
and not exists (select 1 from cost_centers c where c.org_unit_id = k.unit_id)
),
treffer as (
select distinct on (k.position_id) k.position_id, c.id as cost_center_id, k.valid_from
from kette k
join cost_centers c on c.org_unit_id = k.unit_id
order by k.position_id, k.tiefe
)
insert into position_cost_centers (position_id, cost_center_id, valid_from)
select position_id, cost_center_id, valid_from from treffer;
-- ── Umkontieren ────────────────────────────────────────────────────────
--
-- Zum Stichtag: die laufende Zuordnung wird beendet, die neue beginnt. Kein
-- UPDATE der alten Zeile — sonst wäre die Vergangenheit nachträglich anders.
create or replace function set_position_cost_center(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_cost_center_id uuid := (payload->>'cost_center_id')::uuid;
v_ab date := coalesce(nullif(payload->>'valid_from', '')::date, current_date);
v_position om_positions%rowtype;
v_laufend position_cost_centers%rowtype;
v_neu cost_centers%rowtype;
v_alt text;
v_label text;
begin
perform require_hr_admin();
select * into v_position from om_positions where id = v_position_id;
if not found then
raise exception 'Die Planstelle existiert nicht.';
end if;
select * into v_neu from cost_centers where id = v_cost_center_id;
if not found then
raise exception 'Die Kostenstelle existiert nicht.';
end if;
if v_neu.valid_to is not null and v_neu.valid_to <= v_ab then
raise exception 'Die Kostenstelle % ist zum % nicht mehr gültig.', v_neu.code, v_ab;
end if;
-- Vor dem Bestehen der Planstelle gibt es nichts zu kontieren.
if v_ab < v_position.valid_from then
raise exception 'Die Planstelle besteht erst ab %.', v_position.valid_from;
end if;
select * into v_laufend from position_cost_centers
where position_id = v_position_id and valid_to is null for update;
if found then
if v_laufend.cost_center_id = v_cost_center_id then
raise exception 'Diese Planstelle ist bereits auf % kontiert.', v_neu.code;
end if;
-- Ein gleichtägiger Wechsel würde einen Zeitraum der Länge null erzeugen;
-- chk_pcc_range verbietet das, und zu Recht: er hätte nie gegolten.
if v_ab <= v_laufend.valid_from then
raise exception 'Die laufende Kontierung gilt erst ab %. Der Wechsel muss danach liegen.', v_laufend.valid_from;
end if;
select code into v_alt from cost_centers where id = v_laufend.cost_center_id;
update position_cost_centers set valid_to = v_ab where id = v_laufend.id;
end if;
insert into position_cost_centers (position_id, cost_center_id, valid_from)
values (v_position_id, v_cost_center_id, v_ab);
select j.title || ' (' || o.name || ')' into v_label
from om_positions p join jobs j on j.id = p.job_id join org_units o on o.id = p.org_unit_id
where p.id = v_position_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Kostenstelle geändert', v_label,
coalesce('Von ' || v_alt || ' ', '') || 'auf ' || v_neu.code || ' — ' || v_neu.name || ', ab ' || v_ab,
jsonb_build_array(jsonb_build_object('feld', 'Kostenstelle', 'vorher', v_alt, 'nachher', v_neu.code)));
end;
$function$;
revoke all on function set_position_cost_center(jsonb) from public;
grant execute on function set_position_cost_center(jsonb) to public;

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { kontierungAm, kontierungZum, type KontierungsZeile } from "@/lib/cost-centers";
// Eine Umkontierung ist ein Ereignis mit Stichtag. Wer nach den Kosten des
// Vorjahres fragt, muss die Kostenstelle von damals bekommen — sonst
// verändert jede Umkontierung rückwirkend jede alte Auswertung.
function zeile(teil: Partial<KontierungsZeile>): KontierungsZeile {
return {
position_id: "p1",
cost_center_id: "k1",
code: "21010",
name: "Fertigung",
valid_from: "2020-01-01",
valid_to: null,
...teil,
};
}
describe("kontierungZum", () => {
const alt = zeile({ cost_center_id: "k1", code: "21010", valid_from: "2020-01-01", valid_to: "2026-09-01" });
const neu = zeile({ cost_center_id: "k2", code: "21090", name: "Qualitätssicherung", valid_from: "2026-09-01" });
it("liefert die Kostenstelle, die am Stichtag galt", () => {
expect(kontierungZum([alt, neu], "2026-08-31")?.get("p1")?.code).toBe("21010");
expect(kontierungZum([alt, neu], "2026-09-01")?.get("p1")?.code).toBe("21090");
});
it("behandelt den Zeitraum halboffen — der letzte Tag gehört der nächsten", () => {
// Sonst gälte am 01.09. beides, und welche Antwort käme, hinge an der
// Reihenfolge der Zeilen.
expect(kontierungZum([alt, neu], "2026-08-31").get("p1")?.code).toBe("21010");
expect(kontierungZum([neu, alt], "2026-09-01").get("p1")?.code).toBe("21090");
});
it("liefert vor Beginn der ersten Kontierung nichts", () => {
expect(kontierungZum([alt, neu], "2019-12-31").has("p1")).toBe(false);
});
it("liefert nach dem Ende der letzten nichts", () => {
const beendet = zeile({ valid_from: "2020-01-01", valid_to: "2024-01-01" });
expect(kontierungZum([beendet], "2024-01-01").has("p1")).toBe(false);
expect(kontierungZum([beendet], "2023-12-31").has("p1")).toBe(true);
});
it("hält mehrere Planstellen auseinander", () => {
const andere = zeile({ position_id: "p2", cost_center_id: "k3", code: "21030" });
const karte = kontierungZum([alt, neu, andere], "2026-09-01");
expect(karte.get("p1")?.code).toBe("21090");
expect(karte.get("p2")?.code).toBe("21030");
});
it("kommt mit einer leeren Liste zurecht", () => {
expect(kontierungZum([], "2026-09-01").size).toBe(0);
});
});
// Eine Planstelle, die erst entsteht, wird erst mit ihrem Beginn kontiert.
// Zu heute gefragt hätte sie keine Kostenstelle — und die Vakanzliste, die
// künftige Stellen bewusst zeigt, stünde für genau diese ohne da. Das ist beim
// Prüfen gegen die echten Daten aufgefallen, nicht beim Schreiben.
describe("kontierungAm", () => {
const kuenftig = zeile({ position_id: "p9", valid_from: "2027-01-01", code: "21030", name: "Logistik" });
it("liefert zum Beginn der Planstelle die Kostenstelle", () => {
expect(kontierungAm([kuenftig], "p9", "2027-01-01")?.code).toBe("21030");
});
it("liefert davor nichts — sie gilt ja noch nicht", () => {
expect(kontierungAm([kuenftig], "p9", "2026-08-16")).toBeNull();
});
it("verwechselt Planstellen nicht", () => {
expect(kontierungAm([kuenftig], "p1", "2027-01-01")).toBeNull();
});
});