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 { 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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
// 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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user