Make the part-time arrangement a state you can report on

Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.

The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.

It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.

Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.

One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:07:41 +02:00
parent e6554e7982
commit f5ace8af2e
14 changed files with 595 additions and 12 deletions

View File

@@ -131,6 +131,8 @@ export async function recordKarenzReturn(payload: {
weekly_hours?: number; weekly_hours?: number;
/** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */ /** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */
reduction_reason?: string; reduction_reason?: string;
/** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */
teilzeit_bis?: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
} }
@@ -141,12 +143,6 @@ export async function changeEmployeeData(payload: {
person: Record<string, unknown>; person: Record<string, unknown>;
contract: Record<string, unknown>; contract: Record<string, unknown>;
role: Record<string, unknown>; role: Record<string, unknown>;
/**
* Warum sich die Stunden ändern — vertraglich, Bildungs- oder
* Pflegeteilzeit. Kein Feld an der Person: es beschreibt *diese*
* Änderung und steht deshalb in der Historie, nicht im Stammblatt.
*/
hours_reason?: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
} }

View File

@@ -149,6 +149,8 @@ function employeeExportColumns(
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" }, { header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz }, { header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" }, { header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_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 ?? "" }, { header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" }, { header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" }, { header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },

View File

@@ -70,6 +70,7 @@ export function DatenAendernPanel({
// gestellt hat. // gestellt hat.
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]); const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours); const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
const [contractType, setContractType] = useState<ContractType>(employee.contract_type); const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
@@ -140,10 +141,6 @@ export function DatenAendernPanel({
emergency_contact_phone: notfallTelefon.trim(), emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(), emergency_contact_relation: notfallVerhaeltnis.trim(),
}, },
// Nur mitschicken, wenn sich die Stunden wirklich ändern — sonst
// stünde bei jeder Adressänderung ein Grund in der Historie, den
// niemand gemeint hat.
hours_reason: stundenGeaendert ? stundenGrund : undefined,
contract: { contract: {
employment_type: employmentType, employment_type: employmentType,
weekly_hours: Number(weeklyHours), weekly_hours: Number(weeklyHours),
@@ -159,6 +156,16 @@ export function DatenAendernPanel({
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "", dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz, has_kuendigungsschutz: role.hasKuendigungsschutz,
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "", kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
// 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
// zurück, obwohl niemand die Stunden angefasst hat.
...(stundenGeaendert
? {
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
}
: {}),
is_laterale_fuehrung: role.isLateraleFuehrung, is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel, is_c_level: role.isCLevel,
}, },
@@ -285,7 +292,17 @@ export function DatenAendernPanel({
value={stundenGrund} value={stundenGrund}
onChange={setStundenGrund} onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))} options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach in der Historie neben den Stunden." hint="Steht danach am Profil und lässt sich auswerten."
/>
)}
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
<TextField
label="Teilzeit bis"
dense
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/> />
)} )}
<SelectField <SelectField

View File

@@ -37,6 +37,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
// beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb // beginnen typischerweise genau dann, wenn die Abwesenheit endet — deshalb
// steht die Frage hier und nicht in einem zweiten Vorgang danach. // steht die Frage hier und nicht in einem zweiten Vorgang danach.
const [reduktionsgrund, setReduktionsgrund] = useState<string>(""); const [reduktionsgrund, setReduktionsgrund] = useState<string>("");
const [teilzeitBis, setTeilzeitBis] = useState("");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie // Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt. // hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ","); const stundenText = String(employee.weekly_hours).replace(".", ",");
@@ -116,6 +117,7 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
employment_mode: employmentMode, employment_mode: employmentMode,
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined, weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined, reduction_reason: employmentMode === "Teilzeit" ? reduktionsgrund || undefined : undefined,
teilzeit_bis: employmentMode === "Teilzeit" && reduktionsgrund ? teilzeitBis || undefined : undefined,
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
@@ -236,8 +238,17 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
{ value: "", label: "Ohne besonderen Grund" }, { value: "", label: "Ohne besonderen Grund" },
...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })), ...RUECKKEHR_GRUENDE.map((g) => ({ value: g, label: g })),
]} ]}
hint="Steht danach in der Historie, wo auch die Stunden stehen." hint="Steht danach am Profil und lässt sich auswerten."
/> />
{reduktionsgrund && (
<TextField
label="Teilzeit bis"
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
</> </>
)} )}
</> </>

View File

@@ -37,6 +37,16 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["C-Level", employee.is_c_level ? "Ja" : "Nein"], ["C-Level", employee.is_c_level ? "Ja" : "Nein"],
// Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der // Das Enddatum steht gleich dabei: „Ja" allein liesse offen, ob der
// Schutz noch läuft, und genau danach fragt man. // Schutz noch läuft, und genau danach fragt man.
// Die Teilzeitvariante gehört neben die Stunden: sie erklärt, warum sie
// sind, wie sie sind.
[
"Teilzeitvariante",
employee.teilzeit_art
? employee.teilzeit_bis
? `${employee.teilzeit_art} bis ${fmtDate(employee.teilzeit_bis)}`
: `${employee.teilzeit_art} (Ende offen)`
: "–",
],
[ [
"Besonderer Kündigungsschutz", "Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz employee.has_kuendigungsschutz

View File

@@ -58,6 +58,12 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
}, },
{ key: "dienstwagenArt", label: "Antriebsart Dienstwagen", alle: "Alle Antriebsarten", optionen: werte("Verbrenner", "Elektro") }, { 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: "absence", label: "Art der Langzeitabwesenheit", alle: "Alle Arten", optionen: werte(...ABSENCE_TYPES) },
{
key: "teilzeitArt",
label: "Teilzeitvariante",
alle: "Alle Teilzeitvarianten",
optionen: werte("Bildungsteilzeit", "Elternteilzeit", "Pflegeteilzeit", "Wiedereingliederungsteilzeit"),
},
{ key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") }, { key: "weekday", label: "Arbeitet am", alle: "Beliebiger Wochentag", optionen: werte("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") },
] as const; ] as const;
@@ -70,6 +76,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
{ key: "lateral", label: "Laterale Führung" }, { key: "lateral", label: "Laterale Führung" },
{ key: "clevel", label: "C-Level" }, { key: "clevel", label: "C-Level" },
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" }, { key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
{ key: "dependents", label: "Angehörige erfasst" }, { key: "dependents", label: "Angehörige erfasst" },
] as const; ] as const;
@@ -81,6 +88,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
{ key: "exit", label: "Austritt" }, { key: "exit", label: "Austritt" },
{ key: "birth", label: "Geburtsdatum" }, { key: "birth", label: "Geburtsdatum" },
{ key: "schutz", label: "Kündigungsschutz bis" }, { key: "schutz", label: "Kündigungsschutz bis" },
{ key: "teilzeitEnde", label: "Teilzeit endet" },
] as const; ] as const;
export type Criteria = { export type Criteria = {

View File

@@ -23,6 +23,7 @@ import type {
HistoryEventType, HistoryEventType,
PaygradeType, PaygradeType,
SourceType, SourceType,
TeilzeitArt,
WorkerType, WorkerType,
} from "./supabase/types"; } from "./supabase/types";
@@ -63,6 +64,7 @@ export function applyCriteria<O>(
if (a.source) q = q.where("source", "=", a.source as SourceType); if (a.source) q = q.where("source", "=", a.source as SourceType);
if (a.gender) q = q.where("gender", "=", a.gender as GenderType); 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.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.absence) q = q.where("absence_type", "=", a.absence);
const j = c.jaNein; const j = c.jaNein;
@@ -71,6 +73,9 @@ export function applyCriteria<O>(
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "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.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "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);
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich // Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung. // mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
@@ -82,6 +87,8 @@ export function applyCriteria<O>(
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis); 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?.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.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);
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon); if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis); if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
@@ -162,6 +169,7 @@ const SNAPSHOT_EMPLOYEE_COLUMNS = [
"has_dienstwagen", "has_dienstwagen",
"is_laterale_fuehrung", "is_laterale_fuehrung",
"is_c_level", "is_c_level",
"teilzeit_art",
] as const; ] as const;
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen — // Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
@@ -233,6 +241,7 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
has_dienstwagen: e.has_dienstwagen, has_dienstwagen: e.has_dienstwagen,
is_laterale_fuehrung: e.is_laterale_fuehrung, is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level, is_c_level: e.is_c_level,
teilzeit_art: e.teilzeit_art,
dependents_count: dependentsCounts.get(e.id) ?? 0, dependents_count: dependentsCounts.get(e.id) ?? 0,
}); });
} }

View File

@@ -25,6 +25,7 @@ export type GroupDimension =
| "laterale_fuehrung" | "laterale_fuehrung"
| "c_level" | "c_level"
| "has_dependents" | "has_dependents"
| "teilzeit_art"
| "weekday"; | "weekday";
export const MEASURE_LABELS: Record<Measure, string> = { export const MEASURE_LABELS: Record<Measure, string> = {
@@ -55,6 +56,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
laterale_fuehrung: "Laterale Führung", laterale_fuehrung: "Laterale Führung",
c_level: "C-Level", c_level: "C-Level",
has_dependents: "Hat Angehörige", has_dependents: "Hat Angehörige",
teilzeit_art: "Teilzeitvariante",
weekday: "Wochentag", weekday: "Wochentag",
}; };
@@ -101,6 +103,7 @@ export type ReportEmployee = {
has_dienstwagen: boolean; has_dienstwagen: boolean;
is_laterale_fuehrung: boolean; is_laterale_fuehrung: boolean;
is_c_level: boolean; is_c_level: boolean;
teilzeit_art: string | null;
dependents_count: number; dependents_count: number;
}; };
@@ -182,6 +185,9 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
return e.is_c_level ? "Ja" : "Nein"; return e.is_c_level ? "Ja" : "Nein";
case "has_dependents": case "has_dependents":
return e.dependents_count > 0 ? "Ja" : "Nein"; return e.dependents_count > 0 ? "Ja" : "Nein";
case "teilzeit_art":
// „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen.
return e.teilzeit_art ?? "Keine";
case "weekday": case "weekday":
// Not a strict partition — see groupKeysFor, which aggregateReport // Not a strict partition — see groupKeysFor, which aggregateReport
// actually uses. This single-key fallback only covers a direct // actually uses. This single-key fallback only covers a direct

View File

@@ -22,6 +22,15 @@ export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
*/ */
export type DienstwagenArt = "Verbrenner" | "Elektro"; export type DienstwagenArt = "Verbrenner" | "Elektro";
/**
* Gesetzlich geregelte Teilzeiten.
*
* Keine Abwesenheiten: die Person arbeitet, nur kürzer. Als Zustand geführt,
* damit sich auswerten lässt, wer gerade in einer ist — mit einem optionalen
* Enddatum, weil nicht jede ein bekanntes Ende hat.
*/
export type TeilzeitArt = "Bildungsteilzeit" | "Elternteilzeit" | "Pflegeteilzeit" | "Wiedereingliederungsteilzeit";
/** /**
* Verhältnis zum Notfallkontakt. * Verhältnis zum Notfallkontakt.
* *
@@ -176,6 +185,10 @@ export type Database = {
has_kuendigungsschutz: boolean; has_kuendigungsschutz: boolean;
/** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */ /** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
kuendigungsschutz_bis: string | null; kuendigungsschutz_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. */
teilzeit_bis: string | null;
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */ /** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
emergency_contact_name: string | null; emergency_contact_name: string | null;
emergency_contact_phone: string | null; emergency_contact_phone: string | null;
@@ -225,6 +238,8 @@ export type Database = {
dienstwagen_art?: DienstwagenArt | null; dienstwagen_art?: DienstwagenArt | null;
has_kuendigungsschutz?: boolean; has_kuendigungsschutz?: boolean;
kuendigungsschutz_bis?: string | null; kuendigungsschutz_bis?: string | null;
teilzeit_art?: TeilzeitArt | null;
teilzeit_bis?: string | null;
emergency_contact_name?: string | null; emergency_contact_name?: string | null;
emergency_contact_phone?: string | null; emergency_contact_phone?: string | null;
emergency_contact_relation?: string | null; emergency_contact_relation?: string | null;

View File

@@ -0,0 +1,350 @@
-- Die Teilzeitvariante als Zustand, nicht nur als Notiz.
--
-- Beim letzten Schritt sind die vier Teilzeiten aus der Abwesenheitsliste
-- gewandert und wurden mit der Änderung festgehalten — im Beschreibungstext
-- der Historie. Damit liess sich nachlesen, *dass* jemand in Bildungsteilzeit
-- ging, aber nicht auswerten, wer gerade in einer ist, und am Profil stand es
-- nirgends.
--
-- Also ein richtiges Feld: teilzeit_art, dazu ein freiwilliges Enddatum.
--
-- Der Einwand von damals — ein Zustand veraltet, weil niemand nachträgt, wann
-- eine Bildungsteilzeit endet — bleibt richtig und ist der Grund für
-- teilzeit_bis. Mit einem Enddatum kann eine Auswertung selbst entscheiden,
-- was noch läuft, statt sich auf gepflegte Daten zu verlassen. Bleibt das
-- Datum leer, heisst das „Ende offen", und das ist eine ehrliche Aussage.
--
-- Geführt wird das Feld über den gewöhnlichen Weg der Änderungen: es steht in
-- app_feld_karte, taucht in der Historie als Feld mit Vorher/Nachher auf und
-- lässt sich dort berichtigen wie jedes andere. Der Anhang am
-- Beschreibungstext aus dem letzten Schritt entfällt dafür — zweimal
-- dasselbe zu schreiben lädt nur dazu ein, dass die zwei Fassungen
-- auseinanderlaufen.
alter table employees
add column if not exists teilzeit_art text,
add column if not exists teilzeit_bis date;
comment on column employees.teilzeit_art is
'Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen vertraglichen Stundenregelung. Keine Abwesenheit — die Person arbeitet, nur kürzer.';
comment on column employees.teilzeit_bis is
'Ende der Teilzeit, falls bekannt. Freiwillig; leer heisst „Ende offen". Erlaubt Auswertungen darüber, was noch läuft, ohne auf nachgepflegte Daten angewiesen zu sein.';
alter table employees drop constraint if exists chk_teilzeit_art;
alter table employees add constraint chk_teilzeit_art
check (teilzeit_art is null or teilzeit_art in
('Bildungsteilzeit', 'Elternteilzeit', 'Pflegeteilzeit', 'Wiedereingliederungsteilzeit'));
alter table employees drop constraint if exists chk_teilzeit_bis;
alter table employees add constraint chk_teilzeit_bis
check (teilzeit_bis is null or teilzeit_art is not null);
comment on constraint chk_teilzeit_bis on employees is
'Ein Enddatum ohne Variante wäre ein Rest ohne Bezug. Umgekehrt ist eine Variante ohne Enddatum ausdrücklich erlaubt — nicht jede Teilzeit hat ein bekanntes Ende.';
create index if not exists idx_employees_teilzeit on employees (teilzeit_art) where teilzeit_art 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'),
'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 bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role')
);
$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[];
-- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
-- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
-- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
-- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
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 ? '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_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 ? '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,
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),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
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,
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,
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$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if not (app_feld_karte() ? 'Teilzeitvariante' and app_feld_karte() ? 'Teilzeit bis') then
raise exception 'Die Feldtabelle kennt die Teilzeitvariante nicht';
end if;
if v_chg not like '%teilzeit_art%' then
raise exception 'change_employee_data schreibt die Teilzeitvariante nicht';
end if;
if v_chg like '%hours_reason%' then
raise exception 'Der alte Anhang am Beschreibungstext steht noch drin';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return schreibt die Teilzeitvariante nicht';
end if;
end
$$;

View File

@@ -0,0 +1,119 @@
-- Die Teilzeitvariante übersteht auch eine geplante Rückkehr.
--
-- record_karenz_return legt für ein Rückkehrdatum in der Zukunft eine Zeile
-- in pending_org_changes an; der Tageslauf wendet sie an. Diese Zeile trug
-- bisher nur Beschäftigungsausmaß und Stunden — die Variante wäre am Stichtag
-- verlorengegangen, und jemand käme in Elternteilzeit zurück, ohne dass es
-- irgendwo stünde ausser im Beschreibungstext der Historie.
--
-- Aufgefallen beim Proben: die Probe hatte ein Rückkehrdatum in der Zukunft
-- gewählt und lief deshalb in genau diesen Zweig.
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 int := 0;
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 position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) 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. Der Nachweis stünde nur in der Historie,
-- auswerten liesse sich nichts.
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
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)
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$;
-- Selbstprüfung.
do $$
declare
v_apply text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if v_apply not like '%teilzeit_art%' then
raise exception 'Der Tageslauf überträgt die Teilzeitvariante nicht';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return legt die Variante nicht in den Vorgang';
end if;
end
$$;

View File

@@ -59,6 +59,8 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
dienstwagen_art: null, dienstwagen_art: null,
has_kuendigungsschutz: false, has_kuendigungsschutz: false,
kuendigungsschutz_bis: null, kuendigungsschutz_bis: null,
teilzeit_art: null,
teilzeit_bis: null,
is_laterale_fuehrung: false, is_laterale_fuehrung: false,
is_c_level: false, is_c_level: false,
title_prefix: [], title_prefix: [],

View File

@@ -52,6 +52,8 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
dienstwagen_art: null, dienstwagen_art: null,
has_kuendigungsschutz: false, has_kuendigungsschutz: false,
kuendigungsschutz_bis: null, kuendigungsschutz_bis: null,
teilzeit_art: null,
teilzeit_bis: null,
is_laterale_fuehrung: false, is_laterale_fuehrung: false,
is_c_level: false, is_c_level: false,
title_prefix: [], title_prefix: [],
@@ -123,3 +125,23 @@ describe("VertragTab — Kündigungsschutz", () => {
expect(wertZu("Besonderer Kündigungsschutz")).toBe("Nein"); expect(wertZu("Besonderer Kündigungsschutz")).toBe("Nein");
}); });
}); });
// Die Teilzeitvarianten sind keine Abwesenheiten, sondern ein Zustand mit
// weniger Stunden. Damit darüber berichtet werden kann, steht die Variante an
// der Person — und dann muss sie auch am Profil zu sehen sein.
describe("VertragTab — Teilzeitvariante", () => {
it("nennt Variante und Ende", () => {
render(<VertragTab employee={mitarbeiter({ teilzeit_art: "Bildungsteilzeit", teilzeit_bis: "2027-06-30" })} />);
expect(wertZu("Teilzeitvariante")).toBe("Bildungsteilzeit bis 30.06.2027");
});
it("sagt es auch, wenn das Ende offen ist", () => {
render(<VertragTab employee={mitarbeiter({ teilzeit_art: "Elternteilzeit", teilzeit_bis: null })} />);
expect(wertZu("Teilzeitvariante")).toBe("Elternteilzeit (Ende offen)");
});
it("bleibt leer, wo keine besteht", () => {
render(<VertragTab employee={mitarbeiter()} />);
expect(wertZu("Teilzeitvariante")).toBe("–");
});
});

View File

@@ -37,6 +37,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
has_dienstwagen: false, has_dienstwagen: false,
is_laterale_fuehrung: false, is_laterale_fuehrung: false,
is_c_level: false, is_c_level: false,
teilzeit_art: null,
dependents_count: 0, dependents_count: 0,
...overrides, ...overrides,
}; };
@@ -310,3 +311,18 @@ describe("aggregateEvents", () => {
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" }); expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });
}); });
}); });
// Auswertbar sein war der Zweck: „wer ist gerade in Bildungsteilzeit" liess
// sich vorher nicht beantworten, weil die Variante nur im Text der Historie
// stand.
describe("Gruppierung nach Teilzeitvariante", () => {
it("gruppiert nach der Variante", () => {
expect(groupKeyFor(emp({ teilzeit_art: "Bildungsteilzeit" }), "teilzeit_art", lookups)).toBe("Bildungsteilzeit");
expect(groupKeyFor(emp({ teilzeit_art: "Elternteilzeit" }), "teilzeit_art", lookups)).toBe("Elternteilzeit");
});
it("nennt das Fehlen „Keine“ statt eines Gedankenstrichs", () => {
// In einer Auswertung ist „keine Teilzeit" eine Aussage, kein Fehlen.
expect(groupKeyFor(emp({ teilzeit_art: null }), "teilzeit_art", lookups)).toBe("Keine");
});
});