From cf5ed5c6c69df87e61c72802911571d91e5b0568 Mon Sep 17 00:00:00 2001 From: Andrei Laas Date: Tue, 29 Sep 2026 19:47:48 +0200 Subject: [PATCH] Vier Befunde, die still falsche Ergebnisse lieferten C.08 -- die Suche mit Bindestrich fand nichts. Das Feld wird per translate an "-/:.," in Leerzeichen zerlegt, die Eingabe aber nicht: "Mueller-Weiss" erzeugte das Muster mueller-weiss%, waehrend im Heuhaufen "mueller weiss" stand. Ohne Bindestrich fand man dieselbe Person. Die Trennzeichen stehen jetzt einmal in lib/employee-search.ts und werden von beiden Seiten des Vergleichs benutzt. C.09 -- der Einheitenfilter verlor jede Person mit vorgemerktem Austritt. "Laufend" war valid_to is null, aber terminate_employee setzt das Ende schon beim Erfassen, Monate vor dem Tag. Jetzt zaehlt auch, was noch laeuft (valid_to > heute). Bewusst ohne valid_from <= heute: ein geplanter Eintritt gehoert in die Liste, sonst fiele er aus dem Filter, obwohl der Status "Geplant" ihn ausdruecklich fuehrt. E.07 -- nur die Kostenstelle zu aendern war unmoeglich. update_position weist einen Aufruf ohne Aenderung ab, und die Umkontierung lief erst bei dessen Erfolg. Sie wird jetzt uebersprungen, wenn sich an den Stammangaben nichts geaendert hat. H.06 -- jedes Speichern erzeugte zusaetzlich "Wochenstunden 30.0 -> 30". Der Vergleich laeuft ueber Text, die Spalte ist numeric(4,1), und das Formular schickt 30. Der Kommentar an der Zeile nannte die Absicht richtig, nur reicht ::numeric dafuer nicht -- es muss auf die Genauigkeit der Spalte gehen. --- app/(app)/employees/page.tsx | 19 ++++- components/positions/EditPositionModal.tsx | 46 +++++++++--- ...000_wochenstunden_ohne_scheinaenderung.sql | 75 +++++++++++++++++++ lib/employee-search.ts | 23 +++++- tests/unit/employee-search.test.ts | 34 ++++++++- 5 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 db/migrations/20260930100000_wochenstunden_ohne_scheinaenderung.sql diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index c7af32b..4f801e5 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -8,7 +8,7 @@ import { StatusChip } from "@/components/ui/StatusChip"; import { currentUserId } from "@/lib/auth/session"; import { sql, withUser } from "@/lib/db"; import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; -import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; +import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search"; import { SORTIERFELDER, naechsteRichtung, @@ -150,7 +150,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps .innerJoin("om_positions as p", "p.id", "a.position_id") .select("a.id") .whereRef("a.employee_id", "=", "employees.id") - .where("a.valid_to", "is", null) + // Laufend **oder noch bevorstehend**: `valid_to is null` allein + // liess jede Person mit vorgemerktem Austritt aus der Einheit + // verschwinden — terminate_employee setzt das Ende schon beim + // Erfassen, Monate vor dem Tag. Gemeldet im Test vom 29.09. + // (C.09). Das Ende ist ausschliessend, deshalb `>` und nicht + // `>=`. Kein `valid_from <= heute`: ein geplanter Eintritt + // gehört in die Liste, sonst fiele er aus dem Einheitenfilter, + // obwohl der Status „Geplant" ihn ausdrücklich führt. + .where((e) => e.or([e("a.valid_to", "is", null), e("a.valid_to", ">", today)])) .where("p.org_unit_id", "in", units) ) ); @@ -190,9 +198,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest // Postgres die Tabelle in wenigen Millisekunden; ein Index auf // demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt. + // Die Trennzeichen stehen in lib/employee-search.ts, weil die + // Eingabe an denselben zerlegt werden muss — standen sie nur hier, + // fand „Müller-Weiß" nichts (C.08). const heuhaufen = nurNamen - ? sql`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')` - : sql`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`; + ? sql`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})` + : sql`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`; q = q.where((eb) => eb.and( // Als Parameter gebunden, nicht in die Abfrage geschrieben. diff --git a/components/positions/EditPositionModal.tsx b/components/positions/EditPositionModal.tsx index c43a3af..0c820d2 100644 --- a/components/positions/EditPositionModal.tsx +++ b/components/positions/EditPositionModal.tsx @@ -63,20 +63,42 @@ export function EditPositionModal({ // Die eigene Leitung zählt nicht als Hindernis für sich selbst. const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id); + // Ob sich an den Stammangaben überhaupt etwas geändert hat. + // + // update_position weist einen Aufruf ohne Änderung mit „Es wurde nichts + // geändert." ab — und weil die Umkontierung erst bei dessen Erfolg lief, + // liess sich die Kostenstelle **allein** nicht setzen: die Meldung kam, und + // die neue Kostenstelle blieb liegen. Wer es trotzdem wollte, musste + // nebenbei ein anderes Feld verstellen. Gemeldet im Test vom 29.09. (E.07). + const stammGeaendert = + jobTitle.trim() !== position.title || + orgUnitId !== position.org_unit_id || + (isChief && !chiefTaken) !== position.is_chief || + validFrom !== position.valid_from || + (validTo || null) !== (position.valid_to ?? null); + async function handleSubmit() { if (!jobTitle.trim() || !orgUnitId || !validFrom) { showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error"); return; } + if (!stammGeaendert && !kostenstelleGeaendert) { + showToast("Es wurde nichts geändert.", "error"); + return; + } setPending(true); - const result = await updatePosition({ - position_id: position.id, - org_unit_id: orgUnitId, - job_title: jobTitle.trim(), - is_chief: isChief && !chiefTaken, - valid_from: validFrom, - valid_to: validTo || null, - }); + // Nur aufrufen, wenn es etwas zu ändern gibt. Sonst ist der Fehlschlag + // dieses Schritts die einzige Antwort auf eine reine Umkontierung. + const result = stammGeaendert + ? await updatePosition({ + position_id: position.id, + org_unit_id: orgUnitId, + job_title: jobTitle.trim(), + is_chief: isChief && !chiefTaken, + valid_from: validFrom, + valid_to: validTo || null, + }) + : { success: true as const }; if (result.success && kostenstelleGeaendert) { // Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener // Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie @@ -96,7 +118,13 @@ export function EditPositionModal({ } setPending(false); if (result.success) { - showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert."); + showToast( + stammGeaendert && kostenstelleGeaendert + ? "Planstelle geändert und umkontiert." + : kostenstelleGeaendert + ? "Planstelle umkontiert." + : "Planstelle geändert." + ); router.refresh(); onClose(); } else { diff --git a/db/migrations/20260930100000_wochenstunden_ohne_scheinaenderung.sql b/db/migrations/20260930100000_wochenstunden_ohne_scheinaenderung.sql new file mode 100644 index 0000000..546e9cb --- /dev/null +++ b/db/migrations/20260930100000_wochenstunden_ohne_scheinaenderung.sql @@ -0,0 +1,75 @@ +-- Keine Vertragsänderung über nichts +-- +-- Gemeldet im Test vom 29.09. (H.06): jedes Speichern in „Daten ändern" +-- erzeugte zusätzlich eine Vertragsänderung „Wochenstunden 30.0 → 30", auch +-- wenn niemand die Stunden angefasst hatte. +-- +-- Der Vergleich läuft über Text. Die Spalte ist numeric(4,1), also liefert +-- `v_old.weekly_hours::text` den Wert „30.0". Das Formular schickt die Zahl +-- 30, und `('30')::numeric::text` ergibt „30" — numeric merkt sich die +-- Nachkommastellen der Eingabe. Zwei verschiedene Zeichenketten für dieselbe +-- Zahl. +-- +-- Der Kommentar an dieser Zeile sagt „Über ::numeric::text, damit 38.50 und +-- 38.5 gleich zählen" — die Absicht stimmte, nur reicht `::numeric` dafür +-- nicht. Die Umwandlung muss auf **die Genauigkeit der Spalte** gehen, dann +-- steht auf beiden Seiten dieselbe Schreibweise. +-- +-- Folgen bisher: Historie und der Ereignisbericht „Vertragsänderung" füllten +-- sich mit Einträgen über nichts, und im Nachtlauf hing an jeder solchen +-- Änderung ein geplanter Vorgang, der nichts tat. + +do $migration$ +declare + v_alt constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric)::text$anker$; + v_neu constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric(4,1))::text$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 = 'change_employee_data' and p.prokind = 'f'; + if v_def is null then raise exception 'change_employee_data 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 Vergleich der Wochenstunden kommt % mal vor, erwartet einmal.', v_anzahl; + end if; + + execute replace(v_def, v_alt, v_neu); +end +$migration$; + + +-- Selbstprüfung: das Neue und das, was an dieser Funktion schon einmal +-- verlorengegangen ist. +do $$ +declare + v_def text; +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 = 'change_employee_data' and p.prokind = 'f'; + + if v_def not like '%::numeric(4,1))::text%' then + raise exception 'change_employee_data vergleicht die Wochenstunden weiter ohne Genauigkeit.'; + end if; + if v_def not like '%''Hay-Grade''%' then + raise exception 'change_employee_data protokolliert den Hay-Grade nicht mehr.'; + end if; + if v_def not like '%nullif(v_person->>''email'', '''')%' then + raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.'; + end if; + if v_def not like '%cornerstone_id%' then + raise exception 'change_employee_data hat die Cornerstone-ID verloren.'; + end if; + if v_def not like '%require_hr_admin()%' then + raise exception 'change_employee_data prueft die Rechte nicht.'; + end if; + if v_def not like '%search_path%' then + raise exception 'change_employee_data hat keinen festen search_path.'; + end if; + + raise notice 'Wochenstunden werden mit der Genauigkeit der Spalte verglichen.'; +end +$$; diff --git a/lib/employee-search.ts b/lib/employee-search.ts index 2d7330f..2b1f7c9 100644 --- a/lib/employee-search.ts +++ b/lib/employee-search.ts @@ -8,6 +8,24 @@ // verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit // Trennzeichen als Wortgrenze. +/** + * Die Zeichen, die im Namen als Wortgrenze zählen. + * + * Sie stehen hier und nicht in der Seite, weil **beide Seiten des Vergleichs** + * sie brauchen: das Feld wird per `translate` an diesen Zeichen in Leerzeichen + * zerlegt, und die Eingabe muss genauso zerlegt werden. Vorher tat es nur das + * Feld — wer „Müller-Weiß" mit Bindestrich suchte, erzeugte das Muster + * `müller-weiß%`, während im Heuhaufen „müller weiß" stand. Null Treffer, + * obwohl die Person da ist; ohne Bindestrich fand man sie. Gemeldet im Test + * vom 29.09. (C.08). + */ +export const TRENNZEICHEN = "-/:.,"; + +/** Für `translate(…, TRENNZEICHEN, TRENNZEICHEN_ERSATZ)`: gleich lang, nur Leerzeichen. */ +export const TRENNZEICHEN_ERSATZ = " ".repeat(TRENNZEICHEN.length); + +const WORTGRENZE = new RegExp(`[\\s${TRENNZEICHEN.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}]+`); + /** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */ export function istPersonalnummer(term: string): boolean { return /^\d+$/.test(term.trim()); @@ -25,7 +43,10 @@ export function istPersonalnummer(term: string): boolean { export function suchMuster(term: string): string[][] { return term .trim() - .split(/\s+/) + // An denselben Zeichen zerlegt wie das Feld, gegen das verglichen wird: + // „Müller-Weiß" wird zu zwei Wörtern, die beide am Wortanfang treffen + // müssen — genau wie „Müller Weiß". + .split(WORTGRENZE) .filter(Boolean) .map((wort) => { // Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text. diff --git a/tests/unit/employee-search.test.ts b/tests/unit/employee-search.test.ts index b4364de..28c6f06 100644 --- a/tests/unit/employee-search.test.ts +++ b/tests/unit/employee-search.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; +import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search"; // Der Anlass: „Winkler H" gab alle sieben Winkler zurück statt der einen // Hannah. Das „H" wurde als Teilzeichenkette gesucht und traf damit T-h-omas, @@ -35,6 +35,38 @@ describe("suchMuster", () => { ]); }); + it("zerlegt an denselben Zeichen wie das Feld, gegen das verglichen wird", () => { + // Das Feld wird per translate an TRENNZEICHEN in Leerzeichen zerlegt. Tat + // es nur das Feld, erzeugte „Müller-Weiß" das Muster „müller-weiß%", + // während im Heuhaufen „müller weiß" stand: null Treffer, obwohl die + // Person da ist. Ohne Bindestrich fand man sie. (C.08, 29.09.) + expect(suchMuster("müller-weiß")).toEqual([ + ["müller%", "% müller%"], + ["weiß%", "% weiß%"], + ]); + }); + + it("zerlegt an jedem der Trennzeichen", () => { + for (const zeichen of [...TRENNZEICHEN]) { + expect(suchMuster(`abc${zeichen}def`), `„${zeichen}" trennt nicht`).toEqual([ + ["abc%", "% abc%"], + ["def%", "% def%"], + ]); + } + }); + + it("findet dieselbe Person mit und ohne Bindestrich", () => { + expect(suchMuster("müller-weiß")).toEqual(suchMuster("müller weiß")); + }); + + it("hält Trennzeichen und Ersatz gleich lang", () => { + // translate() ordnet Zeichen für Zeichen zu. Wäre der Ersatz kürzer, + // fielen die überzähligen Trennzeichen ersatzlos weg und „Müller-Weiß" + // würde im Feld zu „müllerweiß" — die Suche fände es dann wieder nicht. + expect(TRENNZEICHEN_ERSATZ).toHaveLength(TRENNZEICHEN.length); + expect(TRENNZEICHEN_ERSATZ.trim()).toBe(""); + }); + it("kennt keinen Platzhalter mitten im Wort", () => { // Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas. const [, [amAnfang]] = suchMuster("winkler h");