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.
This commit is contained in:
@@ -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<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
|
||||
: sql<string>`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.
|
||||
|
||||
@@ -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({
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user