Kuendigungsschutz bekommt einen Personenkreis, die Behinderung eigene Felder

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.
This commit is contained in:
2026-09-15 22:47:22 +02:00
parent 8d0c9b4b65
commit c25c16e372
13 changed files with 1148 additions and 9 deletions

View File

@@ -65,6 +65,14 @@ export async function hireEmployee(payload: {
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_bis?: string | null;
/** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */
kuendigungsschutz_grund?: string | null;
kuendigungsschutz_ab?: string | null;
ist_beguenstigt_behindert?: boolean;
/** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */
behinderung_grad?: string | null;
behinderung_ab?: string | null;
behinderung_bis?: string | null;
}): Promise<ActionResult & { employeeId?: string }> {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.

View File

@@ -5,6 +5,7 @@ import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/auth/require-hr";
@@ -49,7 +50,15 @@ export async function GET(request: NextRequest) {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
if (!asOf) q = q.where("status", "in", statuses);
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
// „Ausgetreten" liess damit genau die Leute aus, die gerade
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
// und einmal im Speicher.
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
@@ -150,7 +159,17 @@ function employeeExportColumns(
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
{ header: "Bescheid bis", get: (e) => e.behinderung_bis, kind: "date" },
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },

View File

@@ -1,6 +1,11 @@
"use client";
import { SelectField, TextField } from "@/components/ui/Field";
import {
BEGUENSTIGUNG_AB_GRAD,
GRUND_BEGUENSTIGT_BEHINDERT,
KUENDIGUNGSSCHUTZ_GRUENDE,
} from "@/lib/kuendigungsschutz";
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -30,6 +35,25 @@ export type RoleEmploymentValue = {
* einer erfundenen Zahl. Leer heisst „bis auf Weiteres".
*/
kuendigungsschutzBis: string;
/**
* Der Personenkreis — freiwillig, aus demselben Grund wie das Enddatum:
* der Bestand trägt das Kennzeichen seit 20260814120000 ohne Grund, und ein
* Pflichtfeld hätte jede Änderung an diesen Zeilen blockiert.
*/
kuendigungsschutzGrund: string;
/** Beginn des Schutzes — ebenfalls freiwillig. */
kuendigungsschutzAb: string;
/**
* Begünstigte Behinderung. Eigenes Kennzeichen und nicht aus dem
* Personenkreis abgeleitet: die Begünstigung besteht unabhängig davon, ob
* sie als Kündigungsschutz geführt wird. Die Oberfläche stellt den
* Zusammenhang her, die Datenbank koppelt die beiden nicht.
*/
istBeguenstigtBehindert: boolean;
/** Grad in Prozent laut Bescheid. Als Text, weil ein leeres Zahlenfeld sonst 0 wäre. */
behinderungGrad: string;
behinderungAb: string;
behinderungBis: string;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
@@ -134,14 +158,79 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
gibt. Anders als dort aber freiwillig — leer heisst „bis auf
Weiteres", nicht „vergessen". */}
{value.hasKuendigungsschutz && (
<TextField
label="Geschützt bis"
dense
type="date"
value={value.kuendigungsschutzBis}
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
<>
<SelectField
label="Personenkreis"
dense
value={value.kuendigungsschutzGrund}
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
options={[
{ value: "", label: "Nicht erfasst" },
...KUENDIGUNGSSCHUTZ_GRUENDE.map((g) => ({ value: g, label: g })),
]}
hint="Bestimmt, wer einer Beendigung zustimmen muss."
/>
<TextField
label="Geschützt ab"
dense
type="date"
value={value.kuendigungsschutzAb}
onChange={(v) => onChange({ kuendigungsschutzAb: v })}
hint="Optional."
/>
<TextField
label="Geschützt bis"
dense
type="date"
value={value.kuendigungsschutzBis}
onChange={(v) => onChange({ kuendigungsschutzBis: v })}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
</>
)}
</div>
{/* ── Begünstigte Behinderung ──────────────────────────────────
Eigener Block und nicht innerhalb des Kündigungsschutzes: die
Begünstigung besteht für sich, auch wenn niemand sie als Schutz
führt. Der Haken setzt sich mit, wenn der Personenkreis gewählt
wird — das ist eine Hilfe der Oberfläche, keine Bedingung der
Datenbank, und er lässt sich danach wieder abwählen. */}
<div className="grid grid-cols-1 items-end gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT}
onChange={(e) => onChange({ istBeguenstigtBehindert: e.target.checked })}
/>
Begünstigt behindert
</label>
{(value.istBeguenstigtBehindert || value.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT) && (
<>
<TextField
label="Grad der Behinderung"
dense
type="number"
value={value.behinderungGrad}
onChange={(v) => onChange({ behinderungGrad: v })}
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
/>
<TextField
label="Bescheid ab"
dense
type="date"
value={value.behinderungAb}
onChange={(v) => onChange({ behinderungAb: v })}
/>
<TextField
label="Bescheid bis"
dense
type="date"
value={value.behinderungBis}
onChange={(v) => onChange({ behinderungBis: v })}
hint="Leer heisst unbefristet."
/>
</>
)}
</div>
</div>

View File

@@ -14,6 +14,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types";
@@ -86,6 +87,14 @@ export function DatenAendernPanel({
hasDienstwagen: employee.has_dienstwagen ?? false,
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "",
kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "",
istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false,
// Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0
// bedeutet hier „kein Bescheid", nicht „Grad null".
behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad),
behinderungAb: employee.behinderung_ab ?? "",
behinderungBis: employee.behinderung_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
@@ -93,6 +102,10 @@ export function DatenAendernPanel({
function updateRole(patch: Partial<RoleEmploymentValue>) {
setRole((prev) => ({ ...prev, ...patch }));
}
// Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das
// Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert,
// sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden.
const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
function handleEmploymentTypeChange(value: EmploymentType) {
setEmploymentType(value);
@@ -165,7 +178,16 @@ export function DatenAendernPanel({
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
// Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung
// in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion
// räumt ihn auf demselben Weg weg wie beim Dienstwagen.
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "",
kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "",
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? role.behinderungGrad : "",
behinderung_ab: istBehindert ? role.behinderungAb : "",
behinderung_bis: istBehindert ? role.behinderungBis : "",
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar

View File

@@ -7,6 +7,7 @@ import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepAngehoerige } from "./StepAngehoerige";
@@ -116,6 +117,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
async function handleSubmit() {
if (!selectedPosition || !draft.besetzung) return;
// Dieselbe Regel wie im Formular (RoleEmploymentFields): der
// Personenkreis zieht das Kennzeichen mit. Was gezeigt wurde, muss auch
// abgeschickt werden.
const istBehindert = draft.istBeguenstigtBehindert || draft.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
setSubmitting(true);
const result = await hireEmployee({
personnel_number: Number(draft.personnelNumber),
@@ -146,8 +151,15 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
// sonst nicht zu.
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
has_kuendigungsschutz: draft.hasKuendigungsschutz,
// Ohne Schutz kein Enddatum — der CHECK lässt es nicht anders zu.
// Ohne Schutz kein Enddatum, kein Beginn, kein Personenkreis — die
// CHECKs lassen es nicht anders zu.
kuendigungsschutz_bis: draft.hasKuendigungsschutz ? draft.kuendigungsschutzBis || null : null,
kuendigungsschutz_grund: draft.hasKuendigungsschutz ? draft.kuendigungsschutzGrund || null : null,
kuendigungsschutz_ab: draft.hasKuendigungsschutz ? draft.kuendigungsschutzAb || null : null,
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? draft.behinderungGrad || null : null,
behinderung_ab: istBehindert ? draft.behinderungAb || null : null,
behinderung_bis: istBehindert ? draft.behinderungBis || null : null,
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,

View File

@@ -59,6 +59,14 @@ export type HireDraftData = {
hasKuendigungsschutz: boolean;
/** Freiwillig — leer heisst „bis auf Weiteres". */
kuendigungsschutzBis: string;
/** Personenkreis nach dem besonderen Kündigungsschutz; leer heisst „nicht erfasst". */
kuendigungsschutzGrund: string;
kuendigungsschutzAb: string;
istBeguenstigtBehindert: boolean;
/** Grad in Prozent als Text — ein leeres Zahlenfeld wäre sonst 0. */
behinderungGrad: string;
behinderungAb: string;
behinderungBis: string;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
@@ -95,4 +103,10 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
isCLevel: false,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
kuendigungsschutzGrund: "",
kuendigungsschutzAb: "",
istBeguenstigtBehindert: false,
behinderungGrad: "",
behinderungAb: "",
behinderungBis: "",
};

View File

@@ -0,0 +1,766 @@
-- Besonderer Kündigungsschutz: welcher Personenkreis, und seit wann.
-- Dazu die begünstigte Behinderung als eigener Befund.
--
-- Anforderungen 5 und 5a aus dem Workshop.
--
-- ═══ Was vorher da war und warum es nicht reichte ═══
--
-- Seit Migration 20260814120000 gibt es ein Kennzeichen und ein Enddatum. Das
-- beantwortet „darf hier ohne Weiteres beendet werden?" — mehr nicht. Die
-- Personalabteilung braucht aber die zweite Frage mit: *warum* jemand
-- geschützt ist. Davon hängt ab, wer zustimmen muss (Betriebsrat, Gericht,
-- Behindertenausschuss) und wie lange der Schutz nachwirkt. Ohne den Grund
-- musste man im Papierakt nachsehen — also genau dort, wo unter Zeitdruck
-- niemand nachsieht.
--
-- ═══ Warum ein CHECK auf text und kein Aufzählungstyp ═══
--
-- Die Liste der Personenkreise ist Rechtslage, nicht Stammdatum: sie ändert
-- sich mit dem Gesetz. Ein Aufzählungstyp liesse sich zwar erweitern, aber
-- nicht ohne Weiteres umbenennen oder ausdünnen, und ein Wert, den es nicht
-- mehr geben soll, bliebe für immer im Typ stehen. Das Haus macht das bei
-- Abwesenheitsart, Antriebsart, Notizkategorie und Land schon so — siehe
-- CLAUDE.md-Konvention „Enum-Typen 10; ein Teil ist CHECK auf text".
--
-- ═══ Warum die Behinderung eigene Spalten bekommt ═══
--
-- „Begünstigte behinderte ArbeitnehmerInnen" ist einer der Personenkreise,
-- aber der einzige, an dem weitere Angaben hängen: der Grad in Prozent und
-- der Zeitraum des Bescheids. Als Personenkreis allein liesse sich der
-- geforderte Bericht („begünstigt behindert mit wieviel Prozent, ab/bis
-- wann") nicht bauen.
--
-- Die beiden sind trotzdem **nicht** aneinander gekettet. Eine Bedingung
-- „Grad nur, wenn Personenkreis = begünstigt behindert" hätte denselben
-- Nachteil wie jede Kopplung dieser Art: wer den Personenkreis korrigiert,
-- scheiterte an der Bedingung, solange der Grad noch dransteht — also genau
-- beim Geradebiegen eines Fehlers. Die Oberfläche stellt den Zusammenhang
-- her, indem sie die Felder zusammen zeigt; die Datenbank prüft nur, was
-- ohne sein Kennzeichen nicht dastehen darf.
alter table employees
add column if not exists kuendigungsschutz_grund text,
add column if not exists kuendigungsschutz_ab date,
add column if not exists ist_beguenstigt_behindert boolean not null default false,
add column if not exists behinderung_grad integer,
add column if not exists behinderung_ab date,
add column if not exists behinderung_bis date;
comment on column employees.kuendigungsschutz_grund is
'Personenkreis nach dem besonderen Kündigungsschutz. Freiwillig: Altbestand trägt das Kennzeichen ohne Grund, und ein Pflichtfeld hätte diese Zeilen unänderbar gemacht.';
comment on column employees.kuendigungsschutz_ab is
'Beginn des Schutzes, falls bekannt. Gegenstück zu kuendigungsschutz_bis, das es seit 20260814120000 gibt.';
comment on column employees.ist_beguenstigt_behindert is
'Begünstigte:r Behinderte:r nach dem Behinderteneinstellungsgesetz. Eigenes Kennzeichen und nicht aus kuendigungsschutz_grund abgeleitet: die Begünstigung besteht unabhängig davon, ob sie als Kündigungsschutz geführt wird.';
comment on column employees.behinderung_grad is
'Grad der Behinderung in Prozent laut Bescheid. Die Begünstigung beginnt bei 50, erfasst wird aber jeder bescheidmässige Grad.';
comment on column employees.behinderung_ab is 'Beginn laut Bescheid.';
comment on column employees.behinderung_bis is 'Ende laut Bescheid, falls befristet. Leer heisst unbefristet oder nicht erfasst.';
-- Ohne Kennzeichen kein Grund und kein Beginn: beides wäre sonst ein Rest,
-- den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb
-- genügt die einfache Oder-Form.
alter table employees drop constraint if exists chk_kuendigungsschutz_grund;
alter table employees add constraint chk_kuendigungsschutz_grund
check (has_kuendigungsschutz or (kuendigungsschutz_grund is null and kuendigungsschutz_ab is null));
alter table employees drop constraint if exists chk_kuendigungsschutz_personenkreis;
alter table employees add constraint chk_kuendigungsschutz_personenkreis
check (kuendigungsschutz_grund is null or kuendigungsschutz_grund in (
'Eltern',
'Schwangerschaft',
'Karenzurlaub',
'Familienzeit',
'Elternteilzeit',
'Betriebsräte & Ersatzmitglieder',
'Jugendvertrauenspersonen',
'Behindertenvertrauenspersonen & Stellvertreter',
'Präsenz-, Ausbildungs- oder Zivildienst',
'Begünstigte behinderte ArbeitnehmerInnen',
'Familienhospizkarenz',
'Familienhospizteilzeit'
));
comment on constraint chk_kuendigungsschutz_personenkreis on employees is
'Die Personenkreise des besonderen Kündigungsschutzes. Als CHECK auf text und nicht als Aufzählungstyp: die Liste ist Rechtslage und ändert sich mit dem Gesetz — ein Typ liesse einen zurückgenommenen Wert für immer stehen. Die Oberfläche liest dieselbe Liste aus lib/kuendigungsschutz.ts; beide müssen zusammenpassen.';
-- Der Zeitraum muss ein Zeitraum sein. „Bis vor ab" ist kein Grenzfall,
-- sondern ein Tippfehler, und ohne diese Bedingung stünde er in jeder
-- Auswertung.
alter table employees drop constraint if exists chk_kuendigungsschutz_zeitraum;
alter table employees add constraint chk_kuendigungsschutz_zeitraum
check (kuendigungsschutz_ab is null or kuendigungsschutz_bis is null or kuendigungsschutz_ab <= kuendigungsschutz_bis);
alter table employees drop constraint if exists chk_behinderung;
alter table employees add constraint chk_behinderung
check (
ist_beguenstigt_behindert
or (behinderung_grad is null and behinderung_ab is null and behinderung_bis is null)
);
alter table employees drop constraint if exists chk_behinderung_grad;
alter table employees add constraint chk_behinderung_grad
check (behinderung_grad is null or (behinderung_grad between 1 and 100));
comment on constraint chk_behinderung_grad on employees is
'Ein Grad von 0 ist kein erfasster Befund, sondern ein leeres Feld mit einer Zahl darin — dafür ist null da. Über 100 gibt es nicht.';
alter table employees drop constraint if exists chk_behinderung_zeitraum;
alter table employees add constraint chk_behinderung_zeitraum
check (behinderung_ab is null or behinderung_bis is null or behinderung_ab <= behinderung_bis);
-- Für den geforderten Bericht: er fragt nach den Begünstigten, und das sind
-- wenige. Ein Teilindex kostet fast nichts und spart den vollen Durchlauf.
create index if not exists idx_employees_beguenstigt_behindert
on employees (behinderung_grad) where ist_beguenstigt_behindert;
create index if not exists idx_employees_kuendigungsschutz_grund
on employees (kuendigungsschutz_grund) where kuendigungsschutz_grund is not null;
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'),
'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'),
'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'),
-- 'integer' und nicht 'numeric': der Typ geht in update_history_entry
-- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über
-- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete
-- dabei — der Grad ist eine ganze Zahl aus einem Bescheid.
'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'),
'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'),
'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
-- und Rückkehr ihre eigenen Vorgänge haben.
'Status', jsonb_build_array('status', 'employment_status', null),
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
);
$function$;
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
RETURNS uuid
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false);
v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false);
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis,
ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis,
hat_aufenthaltstitel, aufenthaltstitel_bis,
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
)
values (
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false),
v_schutz,
-- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist:
-- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen
-- den ganzen Vorgang zurück.
case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
v_behindert,
case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end,
case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end,
case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end,
coalesce((payload->>'hat_aufenthaltstitel')::boolean, false),
case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false)
then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end,
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
nullif(payload->>'emergency_contact_name', ''),
nullif(payload->>'emergency_contact_phone', ''),
nullif(payload->>'emergency_contact_relation', '')
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$function$;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
aufenthaltstitel_bis = case
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_person ? 'aufenthaltstitel_bis'
then nullif(v_person->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund'
then nullif(v_role->>'kuendigungsschutz_grund','')
else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab'
then nullif(v_role->>'kuendigungsschutz_ab','')::date
else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- ═══ Der Nachtlauf holt nach, was er bisher liegenliess ═══
--
-- Eine auf später datierte Änderung landet als 'contract_change' in
-- pending_org_changes; apply_due_pending_changes schreibt sie am Stichtag in
-- die Zeile. Bisher schrieb es dabei **nur** Person und Vertrag zurück — die
-- ganze Gruppe `role` fehlte. Betriebsrat, Dienstwagen, Kollektivvertrag,
-- Arbeitstage, Teilzeitvariante und der Kündigungsschutz wurden also erfasst,
-- in der Historie vermerkt, als Vertragsänderung protokolliert — und am
-- Stichtag nicht angewendet. Sichtbar wurde das nie: der Eintrag in der Akte
-- steht da, nur die Spalte blieb alt.
--
-- Das ist älter als diese Migration und gehört streng genommen nicht zu ihr.
-- Die neuen Felder erben den Fehler aber unmittelbar, und ihn nur für sie zu
-- beheben hiesse, zwei Klassen von Feldern mit zwei Verhaltensweisen zu
-- hinterlassen. Deshalb hier vollständig.
--
-- Die Reihenfolge der Zuweisungen entspricht der in change_employee_data;
-- dieselben Kopplungen (fällt das Kennzeichen, fällt das Datum) gelten hier
-- genauso, sonst wiese eine Bedingung den Nachtlauf zurück.
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update employees set job_title = coalesce(v_rec.payload->>'new_title', job_title)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_spalte text;
begin
foreach v_spalte in array array[
'kuendigungsschutz_grund', 'kuendigungsschutz_ab',
'ist_beguenstigt_behindert', 'behinderung_grad', 'behinderung_ab', 'behinderung_bis'
] loop
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employees' and column_name = v_spalte
) then
raise exception 'Die Spalte % fehlt', v_spalte;
end if;
if v_hire not like '%' || v_spalte || '%' then
raise exception 'hire_employee nimmt % nicht entgegen', v_spalte;
end if;
if v_chg not like '%' || v_spalte || '%' then
raise exception 'change_employee_data schreibt % nicht', v_spalte;
end if;
if v_lauf not like '%' || v_spalte || '%' then
raise exception 'Der Nachtlauf wendet % nicht an', v_spalte;
end if;
end loop;
-- Die Feldtabelle: ohne sie liesse sich ein Historieneintrag zu diesen
-- Feldern nicht berichtigen (delete_/update_history_entry lesen sie).
foreach v_spalte in array array[
'Kündigungsschutz Personenkreis', 'Kündigungsschutz ab',
'Begünstigt behindert', 'Grad der Behinderung', 'Behinderung ab', 'Behinderung bis'
] loop
if not (app_feld_karte() ? v_spalte) then
raise exception 'Die Feldtabelle kennt % nicht', v_spalte;
end if;
if app_feld_karte()->v_spalte->>2 <> 'role' then
raise exception 'Die Gruppe im payload stimmt bei % nicht', v_spalte;
end if;
end loop;
-- Der Nachtlauf holt jetzt auch die übrigen Rollenfelder nach. Ohne diese
-- Prüfung fiele ein späteres „create or replace" stillschweigend wieder
-- auf die alte, unvollständige Fassung zurück.
if v_lauf not like '%is_betriebsrat%' or v_lauf not like '%collective_agreement%' then
raise exception 'Der Nachtlauf wendet die Rollenfelder nicht an';
end if;
-- Die Bedingungen selbst. Geprüft wird ihre Definition und nicht ein
-- Probeschreiben: diese Migration muss auch auf einer **leeren** Datenbank
-- durchlaufen (CI-Job „migrationen"), und dort gäbe es keine Zeile, an der
-- sich eine Verletzung auslösen liesse — der Versuch ginge folgenlos aus
-- und die Prüfung meldete fälschlich einen Fehler.
if not exists (
select 1 from pg_constraint
where conname = 'chk_kuendigungsschutz_personenkreis'
and pg_get_constraintdef(oid) like '%Familienhospizteilzeit%'
and pg_get_constraintdef(oid) like '%Jugendvertrauenspersonen%'
) then
raise exception 'Die Bedingung auf den Personenkreis fehlt oder kennt die Liste nicht';
end if;
foreach v_spalte in array array[
'chk_kuendigungsschutz_grund', 'chk_kuendigungsschutz_zeitraum',
'chk_behinderung', 'chk_behinderung_grad', 'chk_behinderung_zeitraum'
] loop
if not exists (select 1 from pg_constraint where conname = v_spalte) then
raise exception 'Die Bedingung % fehlt', v_spalte;
end if;
end loop;
end
$$;

57
lib/kuendigungsschutz.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { KuendigungsschutzGrund } from "./types";
// Die Personenkreise des besonderen Kündigungsschutzes — für die Auswahl in
// der Oberfläche und für den Bericht.
//
// Die Liste steht zweimal: hier und als CHECK
// chk_kuendigungsschutz_personenkreis in Migration 20260915120000. Das ist
// eine bewusste Doppelung mit einer Begründung, denn die Alternativen sind
// schlechter: ein Aufzählungstyp liesse einen zurückgenommenen Wert für immer
// im Typ stehen, und eine Tabelle machte aus einer Rechtslage ein Stammdatum,
// das jemand pflegen müsste. `npm run types:check` hält die Spalte gegen die
// Migration; dass die *Werte* zusammenpassen, sichert der Test in
// tests/unit/kuendigungsschutz.test.ts, der die Migration liest.
//
// Warum der Grund freiwillig ist: der Bestand trägt das Kennzeichen seit
// Migration 20260814120000 ohne Grund. Ein Pflichtfeld hätte diese Zeilen
// unänderbar gemacht — jede Änderung an einer davon wäre an einer Angabe
// gescheitert, die niemand mehr nachträgt.
export const KUENDIGUNGSSCHUTZ_GRUENDE: readonly KuendigungsschutzGrund[] = [
"Eltern",
"Schwangerschaft",
"Karenzurlaub",
"Familienzeit",
"Elternteilzeit",
"Betriebsräte & Ersatzmitglieder",
"Jugendvertrauenspersonen",
"Behindertenvertrauenspersonen & Stellvertreter",
"Präsenz-, Ausbildungs- oder Zivildienst",
"Begünstigte behinderte ArbeitnehmerInnen",
"Familienhospizkarenz",
"Familienhospizteilzeit",
] as const;
/**
* Der Personenkreis, an dem die Angaben zur Behinderung hängen.
*
* Die Oberfläche blendet die Felder auf, sobald dieser Kreis gewählt wird.
* Eine Bedingung in der Datenbank ist das ausdrücklich nicht — siehe die
* Begründung in der Migration: wer den Personenkreis korrigiert, scheiterte
* sonst daran, solange der Grad noch dransteht.
*/
export const GRUND_BEGUENSTIGT_BEHINDERT: KuendigungsschutzGrund = "Begünstigte behinderte ArbeitnehmerInnen";
export function istKuendigungsschutzGrund(wert: string | null | undefined): wert is KuendigungsschutzGrund {
return KUENDIGUNGSSCHUTZ_GRUENDE.includes(wert as KuendigungsschutzGrund);
}
/**
* Ob der Grad zur Begünstigung reicht.
*
* Die Grenze liegt bei 50 Prozent (§ 2 Behinderteneinstellungsgesetz).
* Erfasst wird trotzdem jeder bescheidmässige Grad: ein Bescheid über 30
* Prozent ist eine Tatsache, auch wenn daraus kein besonderer Schutz folgt,
* und wer ihn nicht eintragen darf, trägt ihn in ein Freitextfeld ein.
*/
export const BEGUENSTIGUNG_AB_GRAD = 50;

View File

@@ -1,4 +1,6 @@
import { ABSENCE_TYPES } from "./absence";
import { AUSTRITTSART_LABELS, BEENDIGUNGSART_WERTE, type Austrittsart } from "./beendigung";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { parseIsoDateParam } from "./reports";
import type { Weekday } from "./types";
@@ -58,6 +60,22 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
},
{ key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") },
{ key: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) },
{
key: "schutzGrund",
label: "Personenkreis Kündigungsschutz",
alle: "Alle Personenkreise",
optionen: werte(...KUENDIGUNGSSCHUTZ_GRUENDE),
},
// Die Beendigungsart als Auswertungskriterium — für die Fluktuation nach
// Grund. Die Austrittsart (freiwillig/unfreiwillig) steht daneben und wird
// aus derselben Angabe abgeleitet, nicht zweitgespeichert.
{ key: "beendigung", label: "Beendigungsart", alle: "Alle Beendigungsarten", optionen: werte(...BEENDIGUNGSART_WERTE) },
{
key: "austrittsart",
label: "Austrittsart",
alle: "Alle Austrittsarten",
optionen: (Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ wert: a, label: AUSTRITTSART_LABELS[a] })),
},
{
key: "teilzeitArt",
label: "Teilzeitvariante",
@@ -76,6 +94,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
{ key: "lateral", label: "Laterale Führung" },
{ key: "clevel", label: "C-Level" },
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
{ key: "behindert", label: "Begünstigt behindert" },
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
{ key: "aufenthaltstitel", label: "Aufenthaltstitel" },
{ key: "dependents", label: "Angehörige erfasst" },
@@ -88,7 +107,10 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
{ key: "entry", label: "Eintritt" },
{ key: "exit", label: "Austritt" },
{ key: "birth", label: "Geburtsdatum" },
{ key: "schutzAb", label: "Kündigungsschutz ab" },
{ key: "schutz", label: "Kündigungsschutz bis" },
{ key: "behinderungAb", label: "Bescheid Behinderung ab" },
{ key: "behinderungBis", label: "Bescheid Behinderung bis" },
{ key: "teilzeitEnde", label: "Teilzeit endet" },
{ key: "titelEnde", label: "Aufenthaltstitel läuft ab" },
] as const;

View File

@@ -3,6 +3,7 @@ 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,
@@ -22,6 +23,7 @@ import type {
EmploymentType,
GenderType,
HistoryEventType,
KuendigungsschutzGrund,
PaygradeType,
SourceType,
TeilzeitArt,
@@ -67,6 +69,16 @@ export function applyCriteria<O>(
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");
@@ -78,6 +90,7 @@ export function applyCriteria<O>(
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.
@@ -89,6 +102,12 @@ export function applyCriteria<O>(
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

View File

@@ -39,6 +39,29 @@ export type DienstwagenArt = "Verbrenner" | "Elektro";
*/
export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit";
/**
* Personenkreise des besonderen Kündigungsschutzes.
*
* Kein Aufzählungstyp in der Datenbank, sondern ein CHECK auf text
* (chk_kuendigungsschutz_personenkreis): die Liste ist Rechtslage und ändert
* sich mit dem Gesetz — ein Typ liesse einen zurückgenommenen Wert für immer
* stehen. Die Beschriftungen für die Oberfläche stehen in
* lib/kuendigungsschutz.ts und müssen zur Bedingung passen.
*/
export type KuendigungsschutzGrund =
| "Eltern"
| "Schwangerschaft"
| "Karenzurlaub"
| "Familienzeit"
| "Elternteilzeit"
| "Betriebsräte & Ersatzmitglieder"
| "Jugendvertrauenspersonen"
| "Behindertenvertrauenspersonen & Stellvertreter"
| "Präsenz-, Ausbildungs- oder Zivildienst"
| "Begünstigte behinderte ArbeitnehmerInnen"
| "Familienhospizkarenz"
| "Familienhospizteilzeit";
/**
* Verhältnis zum Notfallkontakt.
*
@@ -192,6 +215,21 @@ export type Database = {
has_kuendigungsschutz: boolean;
/** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
kuendigungsschutz_bis: string | null;
/**
* Personenkreis nach dem besonderen Kündigungsschutz — chk_kuendigungsschutz_personenkreis.
* Freiwillig, denn der Altbestand trägt das Kennzeichen ohne Grund.
*/
kuendigungsschutz_grund: KuendigungsschutzGrund | null;
/** Beginn des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
kuendigungsschutz_ab: string | null;
/** Begünstigung nach dem Behinderteneinstellungsgesetz. */
ist_beguenstigt_behindert: boolean;
/** Grad der Behinderung in Prozent laut Bescheid, 1..100 — chk_behinderung_grad. */
behinderung_grad: number | null;
/** Beginn laut Bescheid. Nur mit dem Kennzeichen zusammen erlaubt. */
behinderung_ab: string | null;
/** Ende laut Bescheid, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */
behinderung_bis: string | null;
/** Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen Regelung. */
teilzeit_art: TeilzeitArt | null;
/** Ende der Teilzeit, falls bekannt. Nur mit einer Variante zusammen erlaubt. */
@@ -247,6 +285,12 @@ export type Database = {
dienstwagen_art?: DienstwagenArt | null;
has_kuendigungsschutz?: boolean;
kuendigungsschutz_bis?: string | null;
kuendigungsschutz_grund?: KuendigungsschutzGrund | null;
kuendigungsschutz_ab?: string | null;
ist_beguenstigt_behindert?: boolean;
behinderung_grad?: number | null;
behinderung_ab?: string | null;
behinderung_bis?: string | null;
teilzeit_art?: TeilzeitArt | null;
teilzeit_bis?: string | null;
emergency_contact_name?: string | null;

View File

@@ -18,6 +18,12 @@ const WERT: RoleEmploymentValue = {
isCLevel: false,
hasKuendigungsschutz: false,
kuendigungsschutzBis: "",
kuendigungsschutzGrund: "",
kuendigungsschutzAb: "",
istBeguenstigtBehindert: false,
behinderungGrad: "",
behinderungAb: "",
behinderungBis: "",
};
function feld(teil: Partial<RoleEmploymentValue> = {}) {

View File

@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
BEGUENSTIGUNG_AB_GRAD,
GRUND_BEGUENSTIGT_BEHINDERT,
KUENDIGUNGSSCHUTZ_GRUENDE,
istKuendigungsschutzGrund,
} from "@/lib/kuendigungsschutz";
// Die Liste der Personenkreise steht zweimal: in lib/kuendigungsschutz.ts für
// die Oberfläche und als CHECK in der Migration für die Datenbank. Die
// Doppelung ist begründet (siehe dort) — aber eine begründete Doppelung ist
// immer noch eine, und zwei Listen laufen auseinander. Auffallen würde es
// erst beim Speichern: die Oberfläche böte einen Wert an, den die Bedingung
// zurückweist, und die Benutzerin sähe einen Fehler aus der Datenbank.
//
// Deshalb liest dieser Test die Migration und hält die beiden gegeneinander.
// Er braucht keine Datenbank — die Datei genügt.
const MIGRATION = join(
process.cwd(),
"db/migrations/20260915120000_kuendigungsschutz_personenkreis_und_behinderung.sql"
);
function werteAusDerBedingung(): string[] {
const sql = readFileSync(MIGRATION, "utf8");
const block = sql.match(/chk_kuendigungsschutz_personenkreis\s+check[\s\S]*?\);/)?.[0];
expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined();
// Die Werte stehen als einfach zitierte Zeichenketten in der in-Liste.
return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
}
describe("Die Personenkreise", () => {
it("stimmen mit der Bedingung in der Datenbank überein", () => {
expect(new Set(werteAusDerBedingung())).toEqual(new Set(KUENDIGUNGSSCHUTZ_GRUENDE));
});
it("stehen jeder nur einmal", () => {
expect(new Set(KUENDIGUNGSSCHUTZ_GRUENDE).size).toBe(KUENDIGUNGSSCHUTZ_GRUENDE.length);
});
it("enthalten den Kreis, an dem die Angaben zur Behinderung hängen", () => {
// Wird er umbenannt, blendet die Akte die Felder nicht mehr auf — und
// niemand bemerkt es, weil nichts fehlschlägt.
expect(KUENDIGUNGSSCHUTZ_GRUENDE).toContain(GRUND_BEGUENSTIGT_BEHINDERT);
});
it("weisen zurück, was nicht in der Liste steht", () => {
expect(istKuendigungsschutzGrund("Eltern")).toBe(true);
expect(istKuendigungsschutzGrund("Irgendetwas")).toBe(false);
expect(istKuendigungsschutzGrund("")).toBe(false);
expect(istKuendigungsschutzGrund(null)).toBe(false);
});
});
describe("Die Grenze der Begünstigung", () => {
it("liegt bei 50 Prozent", () => {
expect(BEGUENSTIGUNG_AB_GRAD).toBe(50);
});
});