diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index 371c9b8..79b330e 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -27,6 +27,10 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro // valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie, // der Druck braucht sie nicht. .select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"]) + // Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020 + // Einheiten, die es damals nicht gab (D.06). + .where("valid_from", "<=", asOf) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) .orderBy("org_number") .execute(), ]); diff --git a/components/employees/tabs/VertragTab.tsx b/components/employees/tabs/VertragTab.tsx index 5bbf3cf..f47a655 100644 --- a/components/employees/tabs/VertragTab.tsx +++ b/components/employees/tabs/VertragTab.tsx @@ -5,6 +5,45 @@ import type { Database } from "@/lib/types"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; +/** + * Der Kündigungsschutz mit Grund und Zeitraum. + * + * Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn + * und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030" + * sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09). + * + * Die Wortwahl für den einfachsten Fall bleibt: steht nur das Kennzeichen da, + * heisst es weiter „Ja (Ende offen)". + */ +function kuendigungsschutzText(e: EmployeeRow): string { + if (!e.has_kuendigungsschutz) return "Nein"; + const teile = [ + e.kuendigungsschutz_grund, + e.kuendigungsschutz_ab ? `ab ${fmtDate(e.kuendigungsschutz_ab)}` : null, + e.kuendigungsschutz_bis ? `bis ${fmtDate(e.kuendigungsschutz_bis)}` : null, + ].filter((t): t is string => Boolean(t)); + + if (teile.length === 0) return "Ja (Ende offen)"; + return e.kuendigungsschutz_bis ? teile.join(" · ") : `${teile.join(" · ")} · Ende offen`; +} + +/** + * Die Begünstigung als eigene Zeile, nicht an den Kündigungsschutz gehängt. + * + * Sie führt zwar oft zu besonderem Kündigungsschutz, ist aber ein eigener + * Bescheid — und der Grad gehört zur Behinderung, nicht zum Schutz. + */ +function behinderungText(e: EmployeeRow): string { + if (!e.ist_beguenstigt_behindert) return "Nein"; + const teile = [ + e.behinderung_grad !== null ? `${e.behinderung_grad} %` : null, + e.behinderung_ab ? `Bescheid ab ${fmtDate(e.behinderung_ab)}` : null, + e.behinderung_bis ? `bis ${fmtDate(e.behinderung_bis)}` : null, + ].filter((t): t is string => Boolean(t)); + + return teile.length > 0 ? teile.join(" · ") : "Ja"; +} + export function VertragTab({ employee }: { employee: EmployeeRow }) { // Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf, // durch Kommas getrennt — und ein Gedankenstrich, wenn nichts zutraf. Damit @@ -39,14 +78,17 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) { : `${employee.teilzeit_art} (Ende offen)` : "–", ], - [ - "Besonderer Kündigungsschutz", - employee.has_kuendigungsschutz - ? employee.kuendigungsschutz_bis - ? `bis ${fmtDate(employee.kuendigungsschutz_bis)}` - : "Ja (Ende offen)" - : "Nein", - ], + // Grund und Zeitraum stehen dabei, nicht nur das Enddatum. Gemeldet am + // 29.09.: erfasst werden sieben Felder — Personenkreis, Beginn, Ende, + // Behinderung mit Grad und Bescheidzeitraum —, sichtbar war in der Akte + // nur „bis TT.MM.JJJJ". Wer den Grund brauchte, fand ihn nur in der + // Historie oder über einen Bericht. + ["Besonderer Kündigungsschutz", kuendigungsschutzText(employee)], + // Eigene Zeile und nicht an den Kündigungsschutz gehängt: die Begünstigung + // ist ein Bescheid für sich. Sie führt zwar oft zu besonderem + // Kündigungsschutz, ist aber nicht dasselbe, und der Grad gehört zur + // Behinderung, nicht zum Schutz. + ["Begünstigt behindert", behinderungText(employee)], ]; if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]); diff --git a/db/migrations/20260929200000_vorgemerkte_planstelle.sql b/db/migrations/20260929200000_vorgemerkte_planstelle.sql new file mode 100644 index 0000000..f2680b2 --- /dev/null +++ b/db/migrations/20260929200000_vorgemerkte_planstelle.sql @@ -0,0 +1,240 @@ +-- Eine Planstelle, auf die jemand vorgemerkt ist, ist nicht frei +-- +-- Gemeldet im Test vom 29.09. (F.24): eine Person wurde auf eine Planstelle +-- eingestellt, auf die bereits eine Versetzung zum 20.10. vorgemerkt war. Die +-- Planstellenliste führte die Stelle als unbesetzt, der Assistent bot sie an, +-- und das Organigramm zum 20.10. zeigte sie zweifach besetzt. +-- +-- ═══ Warum das schlimmer ist, als es aussieht ═════════════════════ +-- +-- Eine vorgemerkte Versetzung steht in pending_org_changes und noch nicht in +-- position_assignments. „Besetzt?" wird aber allein an position_assignments +-- gefragt — die Stelle ist zum Zeitpunkt der Buchung tatsächlich frei. +-- +-- Auffallen würde es erst in der Nacht zum 20.10.: dann legt der Nachtlauf die +-- Besetzung der vorgemerkten Person an und läuft in +-- `position_assignments_one_holder`, den Teilindex, der genau eine laufende +-- Besetzung je Planstelle zulässt. Der Nachtlauf arbeitet in **einer** +-- Transaktion über alle fälligen Vorgänge; eine einzige solche Buchung bringt +-- ihn also vollständig zum Stehen — auch für alle anderen. Der Fehler entsteht +-- heute und schlägt Wochen später bei jemand anderem zu. +-- +-- ═══ Was hier steht ═══════════════════════════════════════════════ +-- +-- Eine Auskunft, die pending_org_changes mitliest, und ihr Aufruf in den vier +-- Funktionen, die jemanden auf eine Planstelle setzen: hire_employee, +-- rehire_employee, transfer_employee, promote_employee. +-- +-- Abgewiesen wird mit Datum — „ab dem 20.10.2026 vorgemerkt" sagt der Person +-- vor dem Bildschirm, was zu tun ist. Die eigene Vormerkung zählt nicht als +-- Hindernis: wer dieselbe Versetzung noch einmal erfasst, korrigiert sie. + +create or replace function planstelle_vorgemerkt(p_position_id uuid, p_ab date, p_ausser uuid default null) +returns date language sql stable +set search_path = public, pg_temp +as $$ + select min(o.effective_date) + from pending_org_changes o + where o.status = 'pending' + -- Nur die beiden Vorgänge, die eine Planstelle belegen. Eine + -- Vertragsänderung oder eine Karenz trägt keine Zielplanstelle. + and o.change_type in ('transfer', 'promotion') + and o.effective_date >= p_ab + and (p_ausser is null or o.employee_id <> p_ausser) + and nullif(o.payload->>'target_position_id', '')::uuid = p_position_id; +$$; + +comment on function planstelle_vorgemerkt(uuid, date, uuid) is + 'Ab wann eine Planstelle durch eine vorgemerkte Versetzung oder Beförderung belegt ist, oder null. Die Vormerkung der übergebenen Person zählt nicht mit.'; + +grant execute on function planstelle_vorgemerkt(uuid, date, uuid) to alpenwerk_app; + + +-- ═══ hire_employee ════════════════════════════════════════════════ +do $migration$ +declare + v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);$anker$; + v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry); + + declare + v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_entry, null); + begin + if v_vorgemerkt is not null then + raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.', + to_char(v_vorgemerkt, 'DD.MM.YYYY'); + end if; + end; +$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'hire_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'hire_employee ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in hire_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ rehire_employee ══════════════════════════════════════════════ +do $migration$ +declare + v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);$anker$; + v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date); + + declare + v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_date, v_employee_id); + begin + if v_vorgemerkt is not null then + raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.', + to_char(v_vorgemerkt, 'DD.MM.YYYY'); + end if; + end; +$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'rehire_employee ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in rehire_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ transfer_employee ════════════════════════════════════════════ +do $migration$ +declare + v_alt constant text := $anker$where pa.position_id = v_target_position and pa.valid_to is null;$anker$; + v_neu constant text := $anker$where pa.position_id = v_target_position and pa.valid_to is null; + + declare + v_vorgemerkt date := planstelle_vorgemerkt(v_target_position, v_effective, v_employee_id); + begin + if v_vorgemerkt is not null then + raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.', + to_char(v_vorgemerkt, 'DD.MM.YYYY'); + end if; + end; +$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'transfer_employee ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + +-- ═══ promote_employee ═════════════════════════════════════════════ +-- +-- Ohne Zielplanstelle ist v_position null; die Auskunft liefert dann nichts, +-- und der Zweig läuft durch. Eine Beförderung auf derselben Stelle braucht +-- deshalb keine Sonderbehandlung. +do $migration$ +declare + v_alt constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;$anker$; + v_neu constant text := $anker$where pa.position_id = v_position and pa.valid_to is null; + + declare + v_vorgemerkt date := planstelle_vorgemerkt(v_position, v_effective_date, v_employee_id); + begin + if v_vorgemerkt is not null then + raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.', + to_char(v_vorgemerkt, 'DD.MM.YYYY'); + end if; + end; +$anker$; + v_def text; + v_anzahl int; +begin + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f'; + if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if; + + v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt); + if v_anzahl <> 1 then + raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + + +-- ═══ Selbstprüfung ════════════════════════════════════════════════ +do $$ +declare + v_name text; + v_def text; +begin + foreach v_name in array array['hire_employee', 'rehire_employee', 'transfer_employee', 'promote_employee'] loop + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = v_name and p.prokind = 'f'; + + if v_def not like '%planstelle_vorgemerkt(%' then + raise exception '% fragt die vorgemerkten Planstellen nicht ab.', v_name; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception '% prueft die Rechte nicht.', v_name; + end if; + if v_def not like '%search_path%' then + raise exception '% hat keinen festen search_path.', v_name; + end if; + end loop; + + -- Was an diesen Funktionen schon einmal verlorenging. Die Statusumwandlung + -- im Wiedereintritt ist zweimal verschwunden; sie wird hier mitgeprüft, + -- weil diese Migration die Funktion erneut erzeugt. + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f'; + if v_def not like '%end)::employment_status%' then + raise exception 'rehire_employee wandelt den Status nicht um.'; + end if; + if v_def not like '%cornerstone_id%' then + raise exception 'rehire_employee hat die Cornerstone-ID verloren.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f'; + if v_def not like '%valid_from >= v_effective_date%' then + raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.'; + end if; + if v_def not like '%target_position_id%' then + raise exception 'promote_employee hat die Zielplanstelle verloren.'; + end if; + + select pg_get_functiondef(p.oid) into v_def + from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f'; + if v_def not like '%valid_from >= v_effective%' then + raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht mehr ab.'; + end if; + + raise notice 'Vorgemerkte Planstellen: alle vier Buchungswege pruefen sie.'; +end +$$; diff --git a/lib/absence.ts b/lib/absence.ts index 6f9f3d9..df3703e 100644 --- a/lib/absence.ts +++ b/lib/absence.ts @@ -50,7 +50,20 @@ export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzei * geregelte Teilzeiten, die man später wiederfinden können muss — im * Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken. */ -export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const; +export const STUNDEN_GRUENDE = [ + "Vertragliche Stundenänderung", + "Bildungsteilzeit", + "Pflegeteilzeit", + // Die beiden standen zuerst nur in RUECKKEHR_GRUENDE, weil sie typischerweise + // dann beginnen, wenn eine Abwesenheit endet. Typischerweise ist aber nicht + // immer: eine Elternteilzeit kann auch vereinbart werden, ohne dass in + // diesem System je eine Karenz stand — etwa beim zweiten Elternteil oder + // wenn die Karenz vor der Einführung lag. Gemeldet von Lara am 29.09.: + // „Teilzeitvariante Elternteilzeit fehlt". Der Bericht konnte schon immer + // nach allen vier filtern; erfassen liessen sich nur zwei. + "Elternteilzeit", + "Wiedereingliederungsteilzeit", +] as const; export type StundenGrund = (typeof STUNDEN_GRUENDE)[number]; diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index eed1adc..091173c 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -60,7 +60,16 @@ export async function loadOrgAsOf(tx: Tx, asOf: string): Promise // Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise // Nachladen weg: sechs Abfragen, jede vollständig. const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ - tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(), + // Zum Stichtag, wie die Planstellen und die Besetzungen darunter. Hier + // stand die Tabelle ungefiltert: eine Einheit, die es 2020 noch nicht gab, + // erschien in der Organisation von 2020. Gemeldet am 29.09. (D.06). + tx + .selectFrom("org_units") + .select(["id", "parent_id"]) + .where("valid_from", "<=", asOf) + .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) + .orderBy("id") + .execute(), tx .selectFrom("om_positions as p") diff --git a/tests/unit/absence.test.ts b/tests/unit/absence.test.ts index aec3d7e..d272355 100644 --- a/tests/unit/absence.test.ts +++ b/tests/unit/absence.test.ts @@ -92,11 +92,30 @@ describe("Gründe für weniger Stunden", () => { expect([...STUNDEN_GRUENDE]).toContain("Pflegeteilzeit"); }); - it("verteilt die vier Teilzeiten vollständig und ohne Überschneidung", () => { - // Keine darf verlorengehen, und keine an beiden Stellen auftauchen — - // sonst hinge dieselbe Sache an zwei Vorgängen. - const verteilt = [...RUECKKEHR_GRUENDE, ...STUNDEN_GRUENDE].filter((g) => TEILZEITEN.includes(g)); - expect(new Set(verteilt)).toEqual(new Set(TEILZEITEN)); - expect(verteilt.length).toBe(TEILZEITEN.length); + it("bietet alle vier Teilzeiten bei der Stundenänderung an", () => { + // Bis zum 29.09. standen Eltern- und Wiedereingliederungsteilzeit nur bei + // der Rückkehr — mit der Begründung, dass sie typischerweise dann + // beginnen. Typischerweise ist nicht immer, und wer sie ohne + // vorangegangene Abwesenheit vereinbart, fand sie nirgends. Gemeldet von + // Lara: „Teilzeitvariante Elternteilzeit fehlt". + for (const teilzeit of TEILZEITEN) { + expect([...STUNDEN_GRUENDE], `„${teilzeit}" fehlt bei der Stundenänderung`).toContain(teilzeit); + } + }); + + it("überschneidet sich dabei bewusst mit der Rückkehr", () => { + // Die Überschneidung ist kein Versehen: es ist derselbe Wert in derselben + // Spalte, nur aus zwei Anlässen erfasst. Was es nicht geben darf, ist eine + // Teilzeit, die an *keinem* der beiden Wege hängt. + const beide = TEILZEITEN.filter( + (t) => ([...RUECKKEHR_GRUENDE] as string[]).includes(t) && ([...STUNDEN_GRUENDE] as string[]).includes(t) + ); + expect(new Set(beide)).toEqual(new Set(RUECKKEHR_GRUENDE)); + }); + + it("führt den Normalfall nicht als Teilzeit", () => { + // „Vertragliche Stundenänderung" ist keine Variante, sondern ihr Fehlen: + // sie landet als null in teilzeit_art. + expect([...TEILZEITEN]).not.toContain("Vertragliche Stundenänderung"); }); });