Hay-Grade auch in "Daten aendern"
Die Befoerderung konnte ihn schon setzen, "Daten aendern" nicht -- weder das
Formular noch change_employee_data kannten das Feld. Damit war eine Einstufung
nur ueber den Weg "Befoerderung" zu aendern, und eine Richtigstellung ("da
stand von Anfang an die falsche Stufe") ist keine Befoerderung: sie soll keine
Planstelle wechseln und kein Ereignis in der Historie hinterlassen.
Das Feld gehoert zur Gruppe contract und ist damit datiert -- eine Umstufung
gilt ab einem Tag. Es kann also in pending_org_changes landen, und deshalb
steht es auch im Nachtlauf. Genau diese zweite Stelle ist hier schon einmal
vergessen worden: die Gruppe role fehlte dort monatelang, und eine datierte
Aenderung wurde als applied vermerkt, ohne etwas zu tun. Drittens
app_feld_karte, sonst waere der Eintrag in der Historie nicht richtigstellbar.
Alle drei Funktionen werden aus der laufenden Definition gelesen und an genau
einem Anker ergaenzt, nicht aus einer Datei kopiert.
Der Test zur Feldkarte liest jetzt alle Migrationen statt einer bestimmten. Der
feste Dateiname darin trug den Vermerk "die zuletzt gueltige Fassung" und war
schon zwei Migrationen spaeter falsch -- und ein Teil der Feldkarte kommt
inzwischen ohnehin aus einer Punktaenderung statt aus einer vollstaendigen
Fassung.
This commit is contained in:
@@ -13,13 +13,14 @@ import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/types";
|
||||
import type { ContractType, Database, EmploymentType, GenderType, PaygradeType } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -79,6 +80,7 @@ export function DatenAendernPanel({
|
||||
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
|
||||
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
|
||||
const [role, setRole] = useState<RoleEmploymentValue>({
|
||||
workerType: employee.worker_type ?? "Angestellte:r",
|
||||
@@ -176,6 +178,7 @@ export function DatenAendernPanel({
|
||||
weekly_hours: Number(weeklyHours),
|
||||
contract_type: contractType,
|
||||
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
||||
paygrade,
|
||||
},
|
||||
role: {
|
||||
worker_type: role.workerType,
|
||||
@@ -372,6 +375,18 @@ export function DatenAendernPanel({
|
||||
{contractType === "befristet" && (
|
||||
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
|
||||
)}
|
||||
{/* Auch über die Beförderung zu setzen — und trotzdem hier. Eine
|
||||
Umstufung ist nicht immer eine Beförderung: „da stand von
|
||||
Anfang an die falsche Stufe" ist eine Richtigstellung, und die
|
||||
soll keine Planstelle wechseln und kein Ereignis „Beförderung"
|
||||
in der Historie hinterlassen. */}
|
||||
<SelectField
|
||||
label="Hay-Grade"
|
||||
dense
|
||||
value={paygrade}
|
||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
199
db/migrations/20260929100000_hay_grade_in_daten_aendern.sql
Normal file
199
db/migrations/20260929100000_hay_grade_in_daten_aendern.sql
Normal file
@@ -0,0 +1,199 @@
|
||||
-- Der Hay-Grade in „Daten ändern"
|
||||
--
|
||||
-- Die Beförderung konnte ihn schon setzen, „Daten ändern" nicht: weder das
|
||||
-- Formular noch change_employee_data kannten das Feld. Damit war eine
|
||||
-- Einstufung nur über den Weg „Beförderung" zu ändern — und eine Korrektur
|
||||
-- („da stand von Anfang an die falsche Stufe") ist keine Beförderung, sondern
|
||||
-- eine Richtigstellung, die auch keine Planstelle wechseln soll.
|
||||
--
|
||||
-- Das Feld gehört zur Gruppe `contract` und ist damit **datiert**: eine
|
||||
-- Umstufung gilt ab einem Tag, nicht rückwirkend seit immer. Das heisst, sie
|
||||
-- kann in pending_org_changes landen — und deshalb steht sie unten auch im
|
||||
-- Nachtlauf. Genau diese zweite Stelle ist in diesem Projekt schon einmal
|
||||
-- vergessen worden: die Gruppe `role` fehlte dort monatelang, und eine
|
||||
-- datierte Änderung an Betriebsrat oder Kollektivvertrag wurde als `applied`
|
||||
-- vermerkt, ohne etwas zu tun.
|
||||
--
|
||||
-- Drittens app_feld_karte: ohne Eintrag dort liesse sich der Eintrag in der
|
||||
-- Historie hinterher nicht richtigstellen.
|
||||
--
|
||||
-- Alle drei Funktionen werden **nicht** aus einer Datei kopiert, sondern aus
|
||||
-- der laufenden Definition gelesen und an genau einem Anker ergänzt. Jeder
|
||||
-- Anker muss genau einmal vorkommen; sonst bricht die Migration ab, statt an
|
||||
-- einer Stelle zu schreiben, die hier niemand angesehen hat.
|
||||
|
||||
-- ═══ 1. change_employee_data ══════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
-- Der Eintrag im Änderungsprotokoll …
|
||||
v_alt_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;$anker$;
|
||||
v_neu_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
if v_contract ? 'paygrade' then v_contract_changes := app_aenderung(v_contract_changes, 'Hay-Grade', v_old.paygrade::text, v_contract->>'paygrade'); end if;$anker$;
|
||||
|
||||
-- … und die Spalte selbst. coalesce und nicht nullif: die Stufe ist NOT NULL,
|
||||
-- sie lässt sich nicht leeren, nur wechseln.
|
||||
v_alt_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),$anker$;
|
||||
v_neu_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
paygrade = coalesce((v_contract->>'paygrade')::paygrade_type, paygrade),$anker$;
|
||||
|
||||
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 is null then
|
||||
raise exception 'change_employee_data ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt_log, ''))) / length(v_alt_log) <> 1 then
|
||||
raise exception 'Der Anker für das Protokoll kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
if (length(v_def) - length(replace(v_def, v_alt_upd, ''))) / length(v_alt_upd) <> 1 then
|
||||
raise exception 'Der Anker für die Spalte kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
if v_def like '%paygrade%' then
|
||||
raise exception 'change_employee_data kennt den Hay-Grade bereits.';
|
||||
end if;
|
||||
|
||||
execute replace(replace(v_def, v_alt_log, v_neu_log), v_alt_upd, v_neu_upd);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 2. apply_due_pending_changes ═════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),$anker$;
|
||||
v_neu constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
|
||||
paygrade = coalesce((v_rec.payload->'contract'->>'paygrade')::paygrade_type, paygrade),$anker$;
|
||||
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 = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'apply_due_pending_changes ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
|
||||
raise exception 'Der Anker im Nachtlauf kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 3. app_feld_karte ════════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
-- Der zweite Eintrag ist der Typ, den update_history_entry unmittelbar als
|
||||
-- Umwandlung ins SQL setzt. paygrade_type ist seit 20260928140000 eine
|
||||
-- Domain über text; die Prüfung der Stufe passiert damit dort, wo sie
|
||||
-- hingehört, und nicht an einer zweiten Liste in dieser Funktion.
|
||||
v_alt constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),$anker$;
|
||||
v_neu constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Hay-Grade', jsonb_build_array('paygrade', 'paygrade_type', 'contract'),$anker$;
|
||||
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 = 'app_feld_karte' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'app_feld_karte ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
|
||||
raise exception 'Der Anker in der Feldkarte kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
|
||||
--
|
||||
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
|
||||
-- Neuerzeugungen in diesem Projekt schon einmal still verschwunden ist.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_anz 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 not like '%''Hay-Grade''%' then
|
||||
raise exception 'change_employee_data protokolliert den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%v_contract->>''paygrade''%' then
|
||||
raise exception 'change_employee_data schreibt den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'change_employee_data hat die Rechtepruefung verloren.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'change_employee_data hat den search_path verloren.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'change_employee_data hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
-- Die Form, die eine leere Angabe als null schreibt statt als '' (siehe
|
||||
-- 20260923160000). Ginge sie verloren, scheiterte die zweite Person ohne
|
||||
-- E-Mail am eindeutigen Index.
|
||||
if v_def not like '%nullif(v_person->>''email'', '''')%' then
|
||||
raise exception 'change_employee_data hat die Leerstring-Behandlung 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 = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%''contract''->>''paygrade''%' then
|
||||
raise exception 'Der Nachtlauf wendet den Hay-Grade nicht an.';
|
||||
end if;
|
||||
if v_def not like '%v_rec.payload->''role''%' then
|
||||
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
|
||||
end if;
|
||||
-- Zwei Zweige hängen eine Besetzung um: die Versetzung und die Beförderung
|
||||
-- auf eine andere Planstelle. Gezählt und nicht bloss auf Vorhandensein
|
||||
-- geprüft — einer der beiden ist hier schon einmal verschwunden.
|
||||
v_anz := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
|
||||
/ length('insert into position_assignments');
|
||||
if v_anz <> 2 then
|
||||
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anz;
|
||||
end if;
|
||||
-- Und was zuletzt hier verlorenging, bleibt drin.
|
||||
if v_def not like '%emergency_contact_name%' then
|
||||
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
|
||||
end if;
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
|
||||
end if;
|
||||
-- Der Lauf hat keine angemeldete Person: er arbeitet als SECURITY DEFINER
|
||||
-- und ruft deshalb **nicht** require_hr_admin(). Ginge das Kennzeichen bei
|
||||
-- einer Neuerzeugung verloren, liefe er nachts in die Zeilenschutz-Regeln
|
||||
-- und täte nichts mehr.
|
||||
if not exists (
|
||||
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef
|
||||
) then
|
||||
raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.';
|
||||
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 = 'app_feld_karte' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%''Hay-Grade''%' then
|
||||
raise exception 'Die Feldkarte kennt den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'Die Feldkarte hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
|
||||
raise notice 'Hay-Grade steht in Daten aendern, im Nachtlauf und in der Feldkarte.';
|
||||
end
|
||||
$$;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { HAY_GRADE_WERTE } from "./hay-grade";
|
||||
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
|
||||
import { MITARBEITERARTEN } from "./mitarbeiterart";
|
||||
import { EMERGENCY_RELATIONS } from "./types";
|
||||
@@ -27,6 +28,12 @@ import { EMERGENCY_RELATIONS } from "./types";
|
||||
// Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht
|
||||
// nebenbei hier.
|
||||
export const FELD_AUSWAHLEN: Record<string, readonly string[]> = {
|
||||
// Kein Aufzählungstyp mehr, sondern eine Domain über text — die Liste darf
|
||||
// hier also stehen. Angezeigt wird der **gespeicherte** Wert und nicht der
|
||||
// Titel: „-" statt „Generic Grade". In einem Dialog, der eine Zeile der
|
||||
// Historie richtigstellt, ist genau das die richtige Anzeige, denn dort
|
||||
// steht hinterher derselbe Wert.
|
||||
"Hay-Grade": HAY_GRADE_WERTE,
|
||||
"Notfallkontakt Verhältnis": EMERGENCY_RELATIONS,
|
||||
"Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||
Mitarbeiterart: MITARBEITERARTEN,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
|
||||
@@ -9,15 +9,24 @@ import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
|
||||
// landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den
|
||||
// diese Datei beheben soll.
|
||||
//
|
||||
// Geprüft wird gegen die zuletzt gültige Fassung der Feldkarte.
|
||||
const MIGRATION = join(process.cwd(), "db/migrations/20260923120000_firmenemail.sql");
|
||||
// Gelesen werden **alle** Migrationen, nicht eine bestimmte. Hier stand
|
||||
// einmal ein fester Dateiname mit dem Vermerk „die zuletzt gültige Fassung";
|
||||
// der war schon zwei Migrationen später falsch, und ein Teil der Feldkarte
|
||||
// kommt inzwischen ohnehin aus einer Punktänderung an der laufenden
|
||||
// Definition statt aus einer vollständigen Fassung. Eine Bezeichnung, die
|
||||
// irgendwann einmal eingetragen wurde, zählt damit als bekannt — das ist
|
||||
// schwächer als der Stand von heute, aber es ist die Frage, die hier zählt:
|
||||
// ob jemand einen Schlüssel erfunden hat, den es nie gab.
|
||||
const MIGRATIONEN = join(process.cwd(), "db/migrations");
|
||||
|
||||
function bezeichnungenDerFeldkarte(): string[] {
|
||||
const sql = readFileSync(MIGRATION, "utf8");
|
||||
const block = sql.match(/FUNCTION public\.app_feld_karte\(\)[\s\S]*?\$function\$;/)?.[0];
|
||||
expect(block, "app_feld_karte steht nicht mehr in dieser Migration").toBeDefined();
|
||||
// Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'),
|
||||
return [...block!.matchAll(/^\s*'([^']+)',\s+jsonb_build_array\(/gm)].map((m) => m[1]);
|
||||
const muster = /'([^']+)',\s+jsonb_build_array\('[a-z_]+', '[a-z_]+'/g;
|
||||
const gefunden = readdirSync(MIGRATIONEN)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.flatMap((f) => [...readFileSync(join(MIGRATIONEN, f), "utf8").matchAll(muster)].map((m) => m[1]));
|
||||
expect(gefunden.length, "Die Feldkarte steht in keiner Migration mehr").toBeGreaterThan(0);
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => {
|
||||
|
||||
Reference in New Issue
Block a user