Anforderung 9 aus dem Workshop. Migration 20260915140000.
Im Dokument standen die beiden untereinander:
Arbeiter, Angestellte, Lehrlinge
Standard / Praktikant / Geringfuegige Beschaeftigung / Altersteilzeit
Die erste Zeile gibt es schon als worker_type, Lehrling seit 20260910140000.
Die zweite ist eine andere Frage: nicht *als was* jemand angestellt ist,
sondern *in welcher Form*. Beide gelten gleichzeitig — ein Praktikant ist
Arbeiter oder Angestellter, nicht statt dessen. In eine Liste gepresst
muesste man sich fuer eine der Antworten entscheiden und verloere die andere.
NOT NULL mit Vorgabe "Standard": jede Person ist in irgendeiner Form
beschaeftigt, und "nicht erfasst" waere keine Aussage, sondern eine Luecke,
die sich durch jede Auswertung zieht. Der Bestand bekommt damit einen
sichtbaren, korrigierbaren Ausgangswert statt eines leeren Feldes.
Die vier Funktionen wurden vollstaendig neu geschrieben. Die Selbstpruefung
haelt deshalb auch die Felder der vorigen Migration fest: eines beim
Uebertragen zu verlieren waere ein Fehler, der nirgends auffiele — die
Oberflaeche schickte den Wert weiter, und die Funktion ignorierte ihn.
388 lines
16 KiB
TypeScript
388 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,
|
|
Mitarbeiterart,
|
|
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.mitarbeiterart) q = q.where("mitarbeiterart", "=", a.mitarbeiterart as Mitarbeiterart);
|
|
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;
|
|
}
|