Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert — als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das Dropdown im Formular schraenkt die Auswahl darunter ein. Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite zuzuschlagen wuerde jede Fluktuationsquote verfaelschen. Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen. Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg; "Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines zusteht, nicht ob es uebergeben wurde. Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren vorher nur ueber den Export erreichbar. Stammdatenaenderung und Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt, Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export. Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet, hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste man selbst.
367 lines
15 KiB
TypeScript
367 lines
15 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 { 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,
|
|
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);
|
|
|
|
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;
|
|
/** 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;
|
|
}
|