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); }