import type { SelectQueryBuilder } from "kysely"; import type { Schema } from "./db/schema"; import type { Tx } from "./db"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent, type UnitOption, } from "./reports"; import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, HistoryEventType, PaygradeType, SourceType, TeilzeitArt, WorkerType, } from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window // rules. export type ReportFilters = { /** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */ division?: string; location?: string; status?: string; /** Alles Weitere — siehe lib/report-criteria.ts. */ criteria?: Criteria; }; /** * Macht aus den Kriterien Bedingungen auf `employees`. * * Bewusst Zeile für Zeile statt über eine Tabelle Schlüssel→Spalte: so prüft * der Compiler jeden Wert gegen den Spaltentyp. Die Umwandlungen sind keine * blinden Zusicherungen — `parseCriteria` hat den Wert vorher gegen dieselbe * Werteliste geprüft, aus der auch der Aufzählungstyp besteht. * * Nicht hier: Wochentag und Angehörige (siehe passtImSpeicher), Einheit * (Teilbaum) und Status (zum Stichtag abgeleitet) — die drei kann keine * Spaltenbedingung ausdrücken. */ export function applyCriteria( q: SelectQueryBuilder, c: Criteria ): SelectQueryBuilder { const a = c.auswahl; if (a.employment) q = q.where("employment_type", "=", a.employment as EmploymentType); if (a.contract) q = q.where("contract_type", "=", a.contract as ContractType); if (a.worker) q = q.where("worker_type", "=", a.worker as WorkerType); if (a.kv) q = q.where("collective_agreement", "=", a.kv as CollectiveAgreement); if (a.paygrade) q = q.where("paygrade", "=", a.paygrade as PaygradeType); if (a.source) q = q.where("source", "=", a.source as SourceType); if (a.gender) q = q.where("gender", "=", a.gender as GenderType); if (a.dienstwagenArt) q = q.where("dienstwagen_art", "=", a.dienstwagenArt as DienstwagenArt); if (a.teilzeitArt) q = q.where("teilzeit_art", "=", a.teilzeitArt as TeilzeitArt); if (a.absence) q = q.where("absence_type", "=", a.absence); const j = c.jaNein; if (j.dienstwagen) q = q.where("has_dienstwagen", "=", j.dienstwagen === "ja"); if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja"); if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja"); if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja"); if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja"); // „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche. if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null); if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null); if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja"); // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. if (c.zeitraum.entry?.von) q = q.where("entry_date", ">=", c.zeitraum.entry.von); if (c.zeitraum.entry?.bis) q = q.where("entry_date", "<=", c.zeitraum.entry.bis); if (c.zeitraum.exit?.von) q = q.where("exit_date", ">=", c.zeitraum.exit.von); if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis); if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von); if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis); if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von); if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis); if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von); if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis); // Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im // nächsten Quartal aus". if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von); if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis); if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); return q; } export type SnapshotFilters = ReportFilters & { asOf?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; /** * Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und * welches Team über ihr liegen. Ein Bericht gruppiert dann über einen * Kartenzugriff statt über einen Aufstieg im Baum je Zeile. */ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups { const divisionName = new Map(); const departmentName = new Map(); const teamName = new Map(); for (const unit of orgMaps.unitList) { for (const a of ancestorsOf(orgMaps, unit.id)) { if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name); else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name); else if (a.unit_type === "Team") teamName.set(unit.id, a.name); } } return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; } export async function loadOrgLookups(tx: Tx): Promise<{ lookups: OrgLookups; orgMaps: OrgMaps; units: UnitOption[]; locations: { id: string; name: string }[]; }> { const orgMaps = await loadOrgMaps(tx); const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); return { lookups: lookupsFromOrgMaps(orgMaps, locations), orgMaps, // Jede Ebene, nicht nur die Bereiche: wer eine einzelne Abteilung // auswerten will, soll sie nicht über einen Umweg zusammensuchen müssen. // Der Filter greift immer auf den ganzen Teilbaum darunter. units: orgMaps.unitList.map((u) => ({ id: u.id, name: u.name, depth: orgMaps.depthOf.get(u.id) ?? 0, unitType: u.unit_type, })), locations, }; } const SNAPSHOT_EMPLOYEE_COLUMNS = [ "id", "first_name", "last_name", "job_title", "location_id", "employment_type", "contract_type", "entry_date", "exit_date", "weekly_hours", "source", "paygrade", "birth_date", "gender", "karenz_start_date", "karenz_return_date", "worker_type", "collective_agreement", "work_days", "is_betriebsrat", "has_dienstwagen", "is_laterale_fuehrung", "is_c_level", "teilzeit_art", ] as const; // Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — // für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist. export async function loadDependentsCounts(tx: Tx): Promise> { // Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle // Zeilen holt und sie selbst durchgeht. const rows = await tx .selectFrom("employee_dependents") .select(({ fn }) => ["employee_id", fn.countAll().as("anzahl")]) .groupBy("employee_id") .execute(); return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)])); } // Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id"); if (filters.location) q = q.where("location_id", "=", filters.location); return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA); } const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ snapshotQuery().execute(), loadDependentsCounts(tx), loadPlacements(tx, { asOf }), filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst // deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem // Bereich selbst nur die Bereichsleitung sitzt. const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const criteria = filters.criteria ?? LEERE_CRITERIA; const withDerivedStatus: ReportEmployee[] = []; for (const e of data) { const placement = placements.get(e.id); // Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand, // sitzt aber auf keiner Planstelle mehr. const orgUnitId = placement?.current ? placement.orgUnitId : null; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) continue; withDerivedStatus.push({ id: e.id, first_name: e.first_name, last_name: e.last_name, job_title: placement?.jobTitle ?? e.job_title, org_unit_id: orgUnitId, location_id: e.location_id, status: deriveStatusAsOf(e, asOf), employment_type: e.employment_type, contract_type: e.contract_type, entry_date: e.entry_date, exit_date: e.exit_date, weekly_hours: e.weekly_hours, source: e.source, paygrade: e.paygrade, birth_date: e.birth_date, gender: e.gender, worker_type: e.worker_type, collective_agreement: e.collective_agreement, work_days: e.work_days, is_betriebsrat: e.is_betriebsrat, has_dienstwagen: e.has_dienstwagen, is_laterale_fuehrung: e.is_laterale_fuehrung, is_c_level: e.is_c_level, teilzeit_art: e.teilzeit_art, dependents_count: dependentsCounts.get(e.id) ?? 0, }); } const statuses = parseStatuses(filters.status); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); } // Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie // kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. // Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem // Team stand, in das die Person nie versetzt worden war. // // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // unbounded (e.g. "alle Ereignisse bis heute", no start date). export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise { const currentYear = new Date().getFullYear(); const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; function historyQuery() { let q = tx .selectFrom("employee_history") .select(["employee_id", "event_date", "event_type", "description"]) .orderBy("id"); if (from) q = q.where("event_date", ">=", from); if (to) q = q.where("event_date", "<=", to); if (filters.eventType) q = q.where("event_type", "=", filters.eventType); return q; } const [history, employees, assignments, orgMaps] = await Promise.all([ historyQuery().execute(), tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(), tx .selectFrom("position_assignments as a") .innerJoin("om_positions as p", "p.id", "a.position_id") .select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"]) .orderBy("a.employee_id") .execute(), filters.division ? loadOrgMaps(tx) : Promise.resolve(null), ]); const spans = new Map(); for (const a of assignments) { const list = spans.get(a.employee_id) ?? []; list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id }); spans.set(a.employee_id, list); } const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const employeeById = new Map(employees.map((e) => [e.id, e])); const events: ReportEvent[] = []; for (const h of history) { const emp = employeeById.get(h.employee_id); if (!emp) continue; if (filters.location && emp.location_id !== filters.location) continue; const unitId = spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; events.push({ employee_id: emp.id, first_name: emp.first_name, last_name: emp.last_name, job_title: emp.job_title, org_unit_id: unitId, location_id: emp.location_id, event_date: h.event_date, event_type: h.event_type, description: h.description, }); } return events; }