diff --git a/actions/positions.ts b/actions/positions.ts index 571bd13..f9da4c6 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -7,7 +7,7 @@ import { runMutation, type ActionResult } from "@/lib/db/rpc"; const POSITION_PATHS = ["/positions", "/orgchart", "/"]; async function callRpc( - fn: "create_position" | "update_position" | "delete_position", + fn: "create_position" | "update_position" | "delete_position" | "set_position_cost_center", payload: Record, revalidate: string[] ): Promise { @@ -49,3 +49,19 @@ export async function deletePosition(positionId: string): Promise 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 { + return callRpc("set_position_cost_center", payload, POSITION_PATHS); +} diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index c12e473..b11c166 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) { const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today)); 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 ( { - const [openPositions, orgMaps, chiefRows] = await Promise.all([ - loadOpenPositions(tx), - loadOrgMaps(tx), - // 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. - tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(), - ]); - return { openPositions, orgMaps, chiefRows }; + const today = todayIso(); + + const { openPositions, orgMaps, chiefRows, kostenstellen } = await withUser(await currentUserId(), async (tx) => { + // Alles, was ohne Vorwissen geht, in einer Rundreise (lib/db/json.ts). + const g = await tx + .selectNoFrom((eb) => [ + ...orgMapsAbfragen(eb), + jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), + jsonArrayFrom(kostenstellenAbfrage(eb, today)).as("kostenstellen"), + // 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)); @@ -30,5 +47,5 @@ export default async function PositionsPage() { const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) })); - return ; + return ; } diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 4b72ad8..115df27 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -34,6 +34,8 @@ type EmployeeDetailProps = { employee: EmployeeRow; placement: PlacementInfo | null; breadcrumb: string; + /** Die Kostenstelle der laufenden Planstellenbesetzung. */ + kostenstelle: { code: string; name: string } | null; manager: MiniEmployee | null; /** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */ 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; 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 [panel, setPanel] = useState(null); @@ -159,6 +161,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { formalManager={formalManager} directReports={directReports} breadcrumb={breadcrumb} + kostenstelle={kostenstelle} /> )} {tab === "Historie" && } diff --git a/components/employees/tabs/OrganisationTab.tsx b/components/employees/tabs/OrganisationTab.tsx index fcd4ae4..adc706e 100644 --- a/components/employees/tabs/OrganisationTab.tsx +++ b/components/employees/tabs/OrganisationTab.tsx @@ -13,15 +13,39 @@ type OrganisationTabProps = { formalManager: MiniEmployee | null; directReports: MiniEmployee[]; 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 (

Organisationseinheit

{breadcrumb}

+

Kostenstelle

+

+ {kostenstelle ? ( + <> + {kostenstelle.code} + · {kostenstelle.name} + + ) : ( + Keine laufende Planstellenbesetzung + )} +

{/* ?focus= drives the same highlight/auto-expand path the org chart search already uses, so the person is unfolded and centred on diff --git a/components/positions/EditPositionModal.tsx b/components/positions/EditPositionModal.tsx index 5c57ce9..85cf2e3 100644 --- a/components/positions/EditPositionModal.tsx +++ b/components/positions/EditPositionModal.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; -import { updatePosition } from "@/actions/positions"; +import { setPositionCostCenter, updatePosition } from "@/actions/positions"; import { Button } from "@/components/ui/Button"; import { SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; @@ -29,10 +29,15 @@ import type { UnitOption } from "./CreatePositionModal"; export function EditPositionModal({ position, units, + kostenstellen, + heute, onClose, }: { position: OpenPositionResolved; 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; }) { const { showToast } = useToast(); @@ -42,8 +47,18 @@ export function EditPositionModal({ const [isChief, setIsChief] = useState(position.is_chief); const [validFrom, setValidFrom] = useState(position.valid_from); 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 kostenstelleGeaendert = + kostenstelleId !== "" && kostenstellen.find((k) => k.id === kostenstelleId)?.code !== position.kostenstelle?.code; + const unit = units.find((u) => u.id === orgUnitId); // 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); @@ -62,9 +77,26 @@ export function EditPositionModal({ valid_from: validFrom, 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); if (result.success) { - showToast("Planstelle geändert."); + showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert."); router.refresh(); onClose(); } else { @@ -110,6 +142,30 @@ export function EditPositionModal({
+ ({ value: k.id, label: `${k.code} · ${k.name}` }))} + /> + {kostenstelleGeaendert && ( +
+ +

+ Die bisherige Kontierung endet an diesem Tag, die neue beginnt. Was vorher gebucht wurde, bleibt, wo es + gebucht wurde. +

+
+ )} +
); diff --git a/lib/cost-centers.ts b/lib/cost-centers.ts new file mode 100644 index 0000000..96920df --- /dev/null +++ b/lib/cost-centers.ts @@ -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 { + const out = new Map(); + 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> { + 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); +} diff --git a/lib/employee-detail-data.ts b/lib/employee-detail-data.ts index 4884849..d88c9c1 100644 --- a/lib/employee-detail-data.ts +++ b/lib/employee-detail-data.ts @@ -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])), }; diff --git a/lib/positions.ts b/lib/positions.ts index c4ffa8a..75381d6 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -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), }; }); } diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index ca5bb24..68da0a2 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -479,6 +479,49 @@ export type Database = { }; Update: Partial; }; + // 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; + }; + // 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; + }; // 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 }; Returns: string }; update_position: { Args: { payload: Record }; Returns: void }; + set_position_cost_center: { Args: { payload: Record }; Returns: void }; delete_position: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; apply_due_pending_changes: { Args: Record; Returns: number }; diff --git a/supabase/migrations/20260816100000_kostenstellen.sql b/supabase/migrations/20260816100000_kostenstellen.sql new file mode 100644 index 0000000..ed2a68e --- /dev/null +++ b/supabase/migrations/20260816100000_kostenstellen.sql @@ -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; diff --git a/tests/unit/cost-centers.test.ts b/tests/unit/cost-centers.test.ts new file mode 100644 index 0000000..48d9041 --- /dev/null +++ b/tests/unit/cost-centers.test.ts @@ -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 { + 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(); + }); +});