Vier Befunde, die still falsche Ergebnisse lieferten
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s

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.
This commit is contained in:
2026-09-29 19:47:48 +02:00
parent a0b9f6dc80
commit cf5ed5c6c6
5 changed files with 182 additions and 15 deletions

View File

@@ -8,7 +8,7 @@ import { StatusChip } from "@/components/ui/StatusChip";
import { currentUserId } from "@/lib/auth/session"; import { currentUserId } from "@/lib/auth/session";
import { sql, withUser } from "@/lib/db"; import { sql, withUser } from "@/lib/db";
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; 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 { import {
SORTIERFELDER, SORTIERFELDER,
naechsteRichtung, naechsteRichtung,
@@ -150,7 +150,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
.innerJoin("om_positions as p", "p.id", "a.position_id") .innerJoin("om_positions as p", "p.id", "a.position_id")
.select("a.id") .select("a.id")
.whereRef("a.employee_id", "=", "employees.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) .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 // diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf // Postgres die Tabelle in wenigen Millisekunden; ein Index auf
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt. // 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 const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')` ? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`; : sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`;
q = q.where((eb) => q = q.where((eb) =>
eb.and( eb.and(
// Als Parameter gebunden, nicht in die Abfrage geschrieben. // Als Parameter gebunden, nicht in die Abfrage geschrieben.

View File

@@ -63,20 +63,42 @@ export function EditPositionModal({
// Die eigene Leitung zählt nicht als Hindernis für sich selbst. // 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); 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() { async function handleSubmit() {
if (!jobTitle.trim() || !orgUnitId || !validFrom) { if (!jobTitle.trim() || !orgUnitId || !validFrom) {
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error"); showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
return; return;
} }
if (!stammGeaendert && !kostenstelleGeaendert) {
showToast("Es wurde nichts geändert.", "error");
return;
}
setPending(true); setPending(true);
const result = await updatePosition({ // Nur aufrufen, wenn es etwas zu ändern gibt. Sonst ist der Fehlschlag
position_id: position.id, // dieses Schritts die einzige Antwort auf eine reine Umkontierung.
org_unit_id: orgUnitId, const result = stammGeaendert
job_title: jobTitle.trim(), ? await updatePosition({
is_chief: isChief && !chiefTaken, position_id: position.id,
valid_from: validFrom, org_unit_id: orgUnitId,
valid_to: validTo || null, job_title: jobTitle.trim(),
}); is_chief: isChief && !chiefTaken,
valid_from: validFrom,
valid_to: validTo || null,
})
: { success: true as const };
if (result.success && kostenstelleGeaendert) { if (result.success && kostenstelleGeaendert) {
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener // Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie // Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
@@ -96,7 +118,13 @@ export function EditPositionModal({
} }
setPending(false); setPending(false);
if (result.success) { 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(); router.refresh();
onClose(); onClose();
} else { } else {

View File

@@ -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
$$;

View File

@@ -8,6 +8,24 @@
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit // verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
// Trennzeichen als Wortgrenze. // 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. */ /** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
export function istPersonalnummer(term: string): boolean { export function istPersonalnummer(term: string): boolean {
return /^\d+$/.test(term.trim()); return /^\d+$/.test(term.trim());
@@ -25,7 +43,10 @@ export function istPersonalnummer(term: string): boolean {
export function suchMuster(term: string): string[][] { export function suchMuster(term: string): string[][] {
return term return term
.trim() .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) .filter(Boolean)
.map((wort) => { .map((wort) => {
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text. // Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 // 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, // 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", () => { it("kennt keinen Platzhalter mitten im Wort", () => {
// Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas. // Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas.
const [, [amAnfang]] = suchMuster("winkler h"); const [, [amAnfang]] = suchMuster("winkler h");