Anforderungen 5 und 5a aus dem Workshop. Migration 20260915120000.
Bisher gab es ein Kennzeichen und ein Enddatum. Das beantwortet "darf hier
ohne Weiteres beendet werden?" — nicht aber, *warum* jemand geschuetzt ist,
und davon haengt ab, wer zustimmen muss. Nachzusehen war das nur im
Papierakt, also dort, wo unter Zeitdruck niemand nachsieht.
Zwoelf Personenkreise als CHECK auf text und nicht als Aufzaehlungstyp: die
Liste ist Rechtslage und aendert sich mit dem Gesetz, ein Typ liesse einen
zurueckgenommenen Wert fuer immer stehen. Die Oberflaeche liest dieselbe
Liste aus lib/kuendigungsschutz.ts; ein Test liest die Migration und haelt
beide gegeneinander, damit die begruendete Doppelung keine stille wird.
Die begueenstigte Behinderung bekommt Kennzeichen, Grad, Beginn und Ende —
vier Spalten und nicht eine zusammengesetzte, weil in Excel danach
gefiltert und summiert wird. Datenbankseitig sind sie *nicht* an den
Personenkreis gekettet: eine solche Bedingung scheiterte genau dann, wenn
jemand den Kreis korrigiert und der Grad noch dransteht. Die Oberflaeche
stellt den Zusammenhang her.
Dazu zwei Dinge, die auf dem Weg auffielen:
* Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
* Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
damit genau die Leute aus, die gerade ausgetreten sind.
386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
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 { beendigungsartenZu, type Austrittsart } from "./beendigung";
|
|
import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria";
|
|
import {
|
|
deriveStatusAsOf,
|
|
EREIGNIS_UNTERTYP,
|
|
EVENT_DATE_OPEN,
|
|
parseStatuses,
|
|
todayIso,
|
|
type OrgLookups,
|
|
type ReportEmployee,
|
|
type ReportEvent,
|
|
type UnitOption,
|
|
} from "./reports";
|
|
import type {
|
|
CollectiveAgreement,
|
|
ContractType,
|
|
DienstwagenArt,
|
|
EmploymentType,
|
|
GenderType,
|
|
HistoryEventType,
|
|
KuendigungsschutzGrund,
|
|
PaygradeType,
|
|
SourceType,
|
|
TeilzeitArt,
|
|
WorkerType,
|
|
} from "./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<O>(
|
|
q: SelectQueryBuilder<Schema, "employees", O>,
|
|
c: Criteria
|
|
): SelectQueryBuilder<Schema, "employees", O> {
|
|
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);
|
|
if (a.schutzGrund) q = q.where("kuendigungsschutz_grund", "=", a.schutzGrund as KuendigungsschutzGrund);
|
|
if (a.beendigung) q = q.where("exit_reason", "=", a.beendigung);
|
|
// Die Austrittsart ist keine Spalte, sondern eine Einteilung der
|
|
// Beendigungsarten (lib/beendigung.ts). Aus ihr wird deshalb eine
|
|
// Wertemenge statt eines Vergleichs — dieselbe Zuordnung, die auch das
|
|
// Formular verwendet, nur als `in`.
|
|
if (a.austrittsart) {
|
|
const werte = beendigungsartenZu(a.austrittsart as Austrittsart);
|
|
q = werte.length > 0 ? q.where("exit_reason", "in", werte) : q;
|
|
}
|
|
|
|
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");
|
|
if (j.behindert) q = q.where("ist_beguenstigt_behindert", "=", j.behindert === "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.schutzAb?.von) q = q.where("kuendigungsschutz_ab", ">=", c.zeitraum.schutzAb.von);
|
|
if (c.zeitraum.schutzAb?.bis) q = q.where("kuendigungsschutz_ab", "<=", c.zeitraum.schutzAb.bis);
|
|
if (c.zeitraum.behinderungAb?.von) q = q.where("behinderung_ab", ">=", c.zeitraum.behinderungAb.von);
|
|
if (c.zeitraum.behinderungAb?.bis) q = q.where("behinderung_ab", "<=", c.zeitraum.behinderungAb.bis);
|
|
if (c.zeitraum.behinderungBis?.von) q = q.where("behinderung_bis", ">=", c.zeitraum.behinderungBis.von);
|
|
if (c.zeitraum.behinderungBis?.bis) q = q.where("behinderung_bis", "<=", c.zeitraum.behinderungBis.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;
|
|
/** Beendigungsart bzw. Art der Langzeitabwesenheit — nur mit eventType sinnvoll. */
|
|
eventSubtype?: string;
|
|
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<string, string>();
|
|
const departmentName = new Map<string, string>();
|
|
const teamName = new Map<string, string>();
|
|
|
|
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<Map<string, number>> {
|
|
// 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<string>().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<ReportEmployee[]> {
|
|
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<ReportEvent[]> {
|
|
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")
|
|
// exit_reason/absence_type für den Untertyp eines Ereignisses — siehe
|
|
// ReportEvent.subtype in lib/reports.ts, dort steht auch, wo die Grenze
|
|
// dieser Zuordnung liegt.
|
|
.select(["id", "first_name", "last_name", "job_title", "location_id", "exit_reason", "absence_type"])
|
|
.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<string, { from: string; to: string | null; unitId: string }[]>();
|
|
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;
|
|
|
|
const subtype = untertypVon(h.event_type, emp);
|
|
// Der Untertyp schränkt nur innerhalb seines Ereignistyps ein. Ohne die
|
|
// erste Hälfte fielen bei „Alle Ereignistypen" sämtliche Zeilen ohne
|
|
// Untertyp heraus — eine Versetzung hat keine Beendigungsart.
|
|
if (filters.eventSubtype && EREIGNIS_UNTERTYP[h.event_type] && subtype !== filters.eventSubtype) 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,
|
|
subtype,
|
|
});
|
|
}
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Der Untertyp eines Ereignisses, gelesen von der Person.
|
|
*
|
|
* `Rückkehr` erbt die Art der Abwesenheit bewusst nicht: sie wird beim
|
|
* Zurückkommen zurückgesetzt, und was dort heute steht, gehört zur nächsten
|
|
* Abwesenheit, nicht zur beendeten.
|
|
*/
|
|
function untertypVon(
|
|
typ: HistoryEventType,
|
|
emp: { exit_reason: string | null; absence_type: string | null }
|
|
): string | null {
|
|
if (typ === "Austritt") return emp.exit_reason;
|
|
if (typ === "Karenz") return emp.absence_type;
|
|
return null;
|
|
}
|