diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index 5570d3c..16a0329 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -144,7 +144,7 @@ function employeeExportColumns( { header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") }, { header: "Vertragsart", get: (e) => e.contract_type }, { header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" }, - { header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type }, + { header: "Beschäftigtengruppe", get: (e) => e.worker_type }, { header: "Kollektivvertrag", get: (e) => e.collective_agreement }, { header: "Betriebsrat", get: (e) => e.is_betriebsrat }, { header: "Dienstwagen", get: (e) => e.has_dienstwagen }, diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index 8270c9f..2338dd9 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -42,14 +42,18 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen return (
+ {/* „Beschäftigtengruppe" statt „Angestellte:r / Arbeiter:in": ein + Feld, das nach seinen Werten heisst, wird falsch, sobald ein + dritter dazukommt. Den Namen führt der Import schon länger. */} onChange({ workerType: v as WorkerType })} options={[ { value: "Angestellte:r", label: "Angestellte:r" }, { value: "Arbeiter:in", label: "Arbeiter:in" }, + { value: "Lehrling", label: "Lehrling" }, ]} /> 0 ? flags.join(", ") : "–"], diff --git a/db/migrations/20260910140000_lehrling.sql b/db/migrations/20260910140000_lehrling.sql new file mode 100644 index 0000000..7af6ed8 --- /dev/null +++ b/db/migrations/20260910140000_lehrling.sql @@ -0,0 +1,65 @@ +-- Lehrling als dritte Beschäftigtengruppe. +-- +-- worker_type kannte bisher zwei Werte, weil das Feld nach ihnen benannt war: +-- „Angestellte:r / Arbeiter:in". Lehrlinge sind in Österreich die dritte +-- sozialversicherungsrechtliche Gruppe daneben — bislang wurden sie als +-- Angestellte:r oder Arbeiter:in geführt, was sie nicht sind. +-- +-- ═══ Warum das hier allein steht ═══════════════════════════════════ +-- +-- Ein Enum-Wert lässt sich nicht wieder entfernen. Ein Rückbau hiesse: neuen +-- Typ anlegen, die Spalte umhängen, den alten wegwerfen — bei Zeilen, die +-- daran hängen, ein eigener Vorgang. Deshalb steht hier nichts weiter drin, +-- was man gleichzeitig zurücknehmen wollen könnte. +-- +-- ═══ Warum der Prüfblock über den Katalog geht ═════════════════════ +-- +-- scripts/migrate.mjs fährt jede Migration in einer Transaktion. Seit +-- PostgreSQL 12 darf `add value` darin stehen — **benutzen** lässt sich der +-- neue Wert in derselben Transaktion aber nicht: ein Vergleich gegen +-- 'Lehrling'::worker_type scheitert mit „unsafe use of new value". Die +-- Gegenprobe fragt deshalb pg_enum, wo die Beschriftung schlichter Text ist. +-- +-- ═══ Was hier bewusst NICHT steht ══════════════════════════════════ +-- +-- Die Beschriftung in app_feld_karte() heisst weiter +-- 'Angestellte:r/Arbeiter:in', obwohl die Oberfläche das Feld ab jetzt +-- „Beschäftigtengruppe" nennt. Diese Zeichenkette ist dort kein Etikett, +-- sondern ein Schlüssel: employee_history.changes und pending_changes tragen +-- sie in bereits gespeicherten Zeilen, und app_feld_karte() ist die Karte, +-- über die das Zurücksetzen und Korrigieren eines Historieneintrags das Feld +-- wiederfindet. Wer sie umbenennt, ohne die gespeicherten Zeilen mitzuziehen, +-- macht jeden alten Eintrag zu diesem Feld unumkehrbar — ein stiller Verlust, +-- der erst auffällt, wenn jemand eine Änderung zurücknehmen will. + +alter type worker_type add value if not exists 'Lehrling'; + +-- ═══ Gegenprobe ═══════════════════════════════════════════════════ +do $$ +declare + anzahl int; +begin + if not exists ( + select 1 + from pg_enum e + join pg_type t on t.oid = e.enumtypid + where t.typname = 'worker_type' and e.enumlabel = 'Lehrling' + ) then + raise exception 'worker_type kennt Lehrling nicht.'; + end if; + + -- Die beiden alten Werte müssen stehen bleiben: ein Enum, das statt drei + -- Werten nur noch einen neuen trägt, hätte die Spalte unbrauchbar gemacht. + select count(*) into anzahl + from pg_enum e + join pg_type t on t.oid = e.enumtypid + where t.typname = 'worker_type'; + if anzahl <> 3 then + raise exception 'worker_type hat % Werte, erwartet werden 3.', anzahl; + end if; + + -- Die Beschriftung in der Feldkarte bleibt, wie sie war — siehe oben. + if not (app_feld_karte() ? 'Angestellte:r/Arbeiter:in') then + raise exception 'Die Feldkarte kennt worker_type nicht mehr unter der gespeicherten Beschriftung.'; + end if; +end $$; diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md index 443b7fb..810b46a 100644 --- a/docs/datenkatalog.md +++ b/docs/datenkatalog.md @@ -302,7 +302,7 @@ eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026 | `employment_status` | Aktiv, Karenz, Geplant, Ausgetreten | | `employment_type` | Vollzeit, Teilzeit | | `contract_type` | unbefristet, befristet | -| `worker_type` | Angestellte:r, Arbeiter:in | +| `worker_type` | Angestellte:r, Arbeiter:in, Lehrling | | `collective_agreement` | Handel, Süßwaren | | `paygrade_type` | A, B, C, D, E, F | | `source_type` | Intern, Extern | diff --git a/lib/import/schema.ts b/lib/import/schema.ts index 7d5de00..973af15 100644 --- a/lib/import/schema.ts +++ b/lib/import/schema.ts @@ -342,7 +342,7 @@ export const BLATT_PERSONEN: BlattSchema = { name: "Beschäftigtengruppe", ziel: "worker_type", pflicht: false, - typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] }, + typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in", "Lehrling"] }, hinweis: "Leer = Angestellte:r.", beispiel: "Angestellte:r", }, diff --git a/lib/report-criteria.ts b/lib/report-criteria.ts index abe4927..7561c46 100644 --- a/lib/report-criteria.ts +++ b/lib/report-criteria.ts @@ -43,7 +43,7 @@ function werte(...w: string[]): { wert: string; label: string }[] { export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [ { key: "employment", label: "Beschäftigungsart", alle: "Alle Beschäftigungsarten", optionen: werte("Vollzeit", "Teilzeit") }, { key: "contract", label: "Vertragsart", alle: "Alle Vertragsarten", optionen: werte("unbefristet", "befristet") }, - { key: "worker", label: "Angestellte:r / Arbeiter:in", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in") }, + { key: "worker", label: "Beschäftigtengruppe", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in", "Lehrling") }, { key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") }, { key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") }, { key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") }, diff --git a/lib/reports.ts b/lib/reports.ts index e727255..55b2e4e 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -49,7 +49,7 @@ export const GROUP_LABELS: Record = { entry_year: "Eintrittsjahr", source: "Intern/Extern", paygrade: "Paygrade", - worker_type: "Angestellte:r / Arbeiter:in", + worker_type: "Beschäftigtengruppe", collective_agreement: "Kollektivvertrag", betriebsrat: "Betriebsrat", dienstwagen: "Dienstwagen", diff --git a/lib/types.ts b/lib/types.ts index 9a9e233..b43d554 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -9,7 +9,15 @@ export type ContractType = "unbefristet" | "befristet"; export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F"; export type SourceType = "Intern" | "Extern"; export type GenderType = "m" | "w"; -export type WorkerType = "Angestellte:r" | "Arbeiter:in"; +/** + * Die sozialversicherungsrechtliche Beschäftigtengruppe. + * + * Lehrling steht seit September 2026 daneben (Migration 20260910140000). + * Bis dahin wurden Lehrlinge als Angestellte:r oder Arbeiter:in geführt, + * was sie nicht sind — und was jede Auswertung nach dieser Spalte um + * genau diese Personen verfälschte. + */ +export type WorkerType = "Angestellte:r" | "Arbeiter:in" | "Lehrling"; export type CollectiveAgreement = "Handel" | "Süßwaren"; export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; diff --git a/tests/components/RoleEmploymentFields.test.tsx b/tests/components/RoleEmploymentFields.test.tsx new file mode 100644 index 0000000..b3d12fe --- /dev/null +++ b/tests/components/RoleEmploymentFields.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields"; + +// Das Auswahlfeld ist die vierte Liste der Beschäftigtengruppen (neben dem +// Enum, den Auswertungen und dem Import — tests/unit/beschaeftigtengruppe). +// Fehlt ein Wert hier, ist er schlicht nicht einzugeben, und niemand merkt +// es: das Feld sieht vollständig aus. + +const WERT: RoleEmploymentValue = { + workerType: "Angestellte:r", + collectiveAgreement: "Handel", + workDays: ["Mo", "Di", "Mi", "Do", "Fr"], + isBetriebsrat: false, + hasDienstwagen: false, + dienstwagenArt: "Verbrenner", + isLateraleFuehrung: false, + isCLevel: false, + hasKuendigungsschutz: false, + kuendigungsschutzBis: "", +}; + +function feld(teil: Partial = {}) { + render( {}} />); + return screen.getByLabelText("Beschäftigtengruppe"); +} + +describe("Beschäftigtengruppe im Formular", () => { + it("bietet alle drei Gruppen an", () => { + expect(within(feld()).getAllByRole("option").map((o) => o.textContent)).toEqual([ + "Angestellte:r", + "Arbeiter:in", + "Lehrling", + ]); + }); + + it("heisst Beschäftigtengruppe, nicht mehr nach zwei ihrer Werte", () => { + // „Angestellte:r / Arbeiter:in" war der Name bis September 2026 — er + // wurde falsch, sobald ein dritter Wert dazukam. + feld(); + expect(screen.queryByLabelText(/Arbeiter:in/)).not.toBeInTheDocument(); + }); + + it("zeigt die gespeicherte Gruppe als gewählt", () => { + expect(feld({ workerType: "Lehrling" })).toHaveValue("Lehrling"); + }); +}); diff --git a/tests/unit/beschaeftigtengruppe.test.ts b/tests/unit/beschaeftigtengruppe.test.ts new file mode 100644 index 0000000..0501299 --- /dev/null +++ b/tests/unit/beschaeftigtengruppe.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { BLAETTER } from "@/lib/import/schema"; +import { AUSWAHL_KRITERIEN, parseCriteria } from "@/lib/report-criteria"; +import { GROUP_LABELS } from "@/lib/reports"; + +// Die Beschäftigtengruppe (worker_type) steht an vier Stellen als Liste: +// im Datenbank-Enum, in der Auswahl der Auswertungen, in der Prüfliste des +// Imports und im Auswahlfeld der Oberfläche. Sie werden von Hand gepflegt, +// und sie driften auseinander, ohne dass etwas rot wird — ein fehlender Wert +// heisst dort nicht „Fehler", sondern „gibt es nicht": +// +// * fehlt er in der Auswertung, lässt sich nach der Gruppe nicht filtern, +// * fehlt er im Import, wird die Zeile abgewiesen, +// * fehlt er im Auswahlfeld, ist der Wert nicht einzugeben. +// +// Deshalb steht die erwartete Liste hier einmal, und jede Stelle wird +// dagegen gehalten. Das Enum selbst prüft die Migration (20260910140000). + +const GRUPPEN = ["Angestellte:r", "Arbeiter:in", "Lehrling"]; + +function importSpalte() { + for (const blatt of BLAETTER) { + const treffer = blatt.spalten.find((s) => s.ziel === "worker_type"); + if (treffer) return treffer; + } + throw new Error("Keine Importspalte mit ziel worker_type gefunden"); +} + +describe("Beschäftigtengruppe", () => { + it("bietet in den Auswertungen alle drei zur Auswahl", () => { + const kriterium = AUSWAHL_KRITERIEN.find((k) => k.key === "worker"); + expect(kriterium?.optionen.map((o) => o.wert)).toEqual(GRUPPEN); + }); + + it("lässt jede der drei durch die Prüfung der Adresszeile", () => { + // Was nicht in der Liste steht, verwirft parseCriteria stillschweigend — + // der Filter wirkte dann einfach nicht, ohne Meldung. + for (const gruppe of GRUPPEN) { + expect(parseCriteria((k) => (k === "worker" ? gruppe : undefined)).auswahl).toEqual({ worker: gruppe }); + } + }); + + it("verwirft weiterhin, was nicht dazugehört", () => { + // Die Werte landen in SQL-Vergleichen und im Dateinamen des Downloads. + expect(parseCriteria((k) => (k === "worker" ? "Lehrling'; drop table employees; --" : undefined)).auswahl).toEqual({}); + }); + + it("nimmt alle drei im Import an", () => { + const spalte = importSpalte(); + expect(spalte.typ).toMatchObject({ art: "auswahl", werte: GRUPPEN }); + }); + + it("nennt das Feld überall Beschäftigtengruppe", () => { + // Bis September 2026 hiess es „Angestellte:r / Arbeiter:in" — ein Name + // aus seinen beiden Werten, der mit dem dritten falsch wurde. + expect(AUSWAHL_KRITERIEN.find((k) => k.key === "worker")?.label).toBe("Beschäftigtengruppe"); + expect(GROUP_LABELS.worker_type).toBe("Beschäftigtengruppe"); + expect(importSpalte().name).toBe("Beschäftigtengruppe"); + }); +});