Compare commits

51 Commits

Author SHA1 Message Date
96c94642ba Datumsfelder lassen sich wieder tippen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m50s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
In den Berichten hing jedes Datumsfeld unmittelbar an der Adresszeile: eine
Aenderung hiess router.push, also neu laden und neu rendern. Ein
<input type="date"> meldet beim Tippen der Jahreszahl aber viermal -- 0002,
0020, 0202, 2026 -- und die ersten drei loesten je eine Navigation aus, die das
Feld auf den Stand aus der Adresse zuruecksetzte. Mitten im Tippen. Mit dem
Kalender ging es, weil der in einem Zug ein fertiges Datum setzt; das war der
Hinweis darauf, wo es klemmt.

DateField haelt den Tippstand jetzt bei sich und meldet nur, was eine Aussage
ist: ein vollstaendiges Datum mit vierstelliger Jahreszahl ab 1000, oder das
Leeren des Feldes. Beim Verlassen wird nachgereicht, was liegengeblieben ist.
Der Stichtag im Organigramm ist ein blankes input und benutzt dieselbe Regel
ueber istMeldbaresDatum -- zwei Fassungen davon liefen hier erfahrungsgemaess
auseinander.

Der Abgleich mit dem Wert von aussen laeuft waehrend des Renderns, nicht in
einem Effekt: der liefe erst nach dem Zeichnen, das Feld zeigte also fuer einen
Bildaufbau den alten Stand -- und die Regel gegen setState im Effekt verbietet
ihn aus genau diesem Grund.

Die Felder in den Panels bleiben, wie sie sind: dort ist der Zustand lokal, es
gibt keine Navigation und damit auch kein Zuruecksetzen.
2026-09-29 21:39:35 +02:00
d9966c9623 Personalnummer im gedruckten Organigramm, wahlweise
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
Von Max gewuenscht, ausdruecklich mit Auswahl oben. Ein Haekchen in "Was soll
aufs Papier?" stellt die Nummer hinter den Namen -- nicht in eine eigene Zeile:
auf einem Blatt mit zweihundert Kaesten kostet jede zusaetzliche Zeile
Massstab, und der entscheidet darueber, ob das Blatt noch lesbar ist.

Angeboten auf jeder Tiefe, nicht nur bei "mit Personen". Die Leitung einer
Einheit steht auch auf den Blaettern, die sonst keine Namen fuehren; ein
Haekchen, das dort fehlte, saehe aus, als gaebe es dort keine Namen.

Als Kontext statt als Eigenschaft durch den Baum: die Angabe betrifft eine
einzige Zeile ganz unten, muesste aber sonst durch vier Signaturen der
Seitenerzeugung durchgereicht werden. Und die Kennung der Messung traegt den
Schalter mit -- die Zeilen werden breiter, also muss der Massstab neu gemessen
werden, sonst liefe das Blatt ueber den Rand.
2026-09-29 21:03:50 +02:00
0039ce5069 Headcount heisst ueberall dasselbe
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m12s
"Headcount" stand auf der Uebersicht fuer die Aktiven und im Bericht fuer eine
andere Zahl -- 785 gegen 788, dasselbe Wort fuer Verschiedenes (N.02). Max hat
die Begriffe festgelegt: Headcount Aktiv fuer die Aktiven, Headcount Aktives
Dienstverhaeltnis fuer Aktive plus Langzeitabwesende.

Die beiden Kacheln auf der Uebersicht tragen jetzt genau diese Namen. Beide
Zahlen gab es dort schon, nur hiess die eine "Aktives Dienstverhaeltnis" und
die andere "Aktive Mitarbeiter:innen (HC)".

Im Bericht ginge ein fester Name nicht: die Kennzahl zaehlt, was gerade
ausgewaehlt ist, auch Geplante oder Ausgetretene. Trifft die Auswahl eine der
beiden Groessen, steht ihr Name in der Ueberschrift; sonst steht dabei, welche
Status gezaehlt wurden -- mit den Anzeigenamen, also "Langzeitabwesenheit" und
nicht "Karenz".
2026-09-29 20:36:22 +02:00
38f8c99968 Eine Befoerderung benennt die Planstelle um
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Bisher schrieb promote_employee den neuen Titel nach employees.job_title -- und
nur dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die Exporte zeigen
alle die Taetigkeit der Planstelle. Haltbar war er auch nicht, denn
hire_employee, transfer_employee und change_employee_data setzen dieselbe
Spalte aus der Planstelle; die naechste Adressaenderung ueberschrieb ihn wieder.

Gemeldet von Lara und im Testprotokoll als H.10. Max hat entschieden: die
Befoerderung benennt die Planstelle um.

Umbenannt wird die Stelle, auf der die Person danach sitzt -- die Zielstelle,
sonst die bisherige. Im Formular ist das ohne Ueberraschung, weil es beim
Waehlen einer Zielplanstelle deren Taetigkeit als Vorschlag eintraegt: wer
nichts aendert, benennt auch nichts um. Und umbenannt wird nicht der
Katalogeintrag, sondern die Planstelle zeigt auf einen anderen -- den Eintrag
selbst umzubenennen traefe jede Planstelle mit derselben Taetigkeit.

Die Regel "gleiche Taetigkeit, ein Katalogeintrag" stand nur in
create_position und wandert nach job_fuer_titel(); zwei Fassungen derselben
Regel laufen hier erfahrungsgemaess auseinander. Die Nummernvergabe nimmt dabei
die erste freie statt count(*) + 1 -- gezaehlt wurde bisher, und das vergibt
eine belegte Nummer, sobald ein Eintrag geloescht wurde oder Codes aus einer
fremden Quelle danebenstehen, wie bei der Uebernahme der Manner-Daten.

Der Nachtlauf tut dasselbe, sonst benennt eine datierte Befoerderung am
Stichtag nichts um. Der Rauchtest prueft beide Wege und dass der alte
Katalogeintrag stehen bleibt.
2026-09-29 20:29:56 +02:00
fe858c6c24 YTD heisst bis heute, nicht bis zum Jahresende
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Die Kacheln "Eintritte/Austritte (YTD)" zaehlten bis zum 31.12. und damit auch,
was erst bevorsteht: ein fuer den 01.11. erfasster Austritt stand schon im
September als geschehen da. Die Kachel sagte 2, tatsaechlich war es 1.

Der Kommentar an der Kachel nannte die Absicht seit jeher richtig -- "seit
Jahresbeginn bis heute, nicht das ganze Kalenderjahr" --, nur stand darunter
yearEnd. Was kommt, steht ohnehin in "Anstehend" daneben; die Kachel soll
sagen, was war.

Der Verweis auf den Bericht traegt denselben Zeitraum, sonst zeigte der Bericht
eine andere Zahl als die Kachel, ueber die man ihn geoeffnet hat. Und die
Variable heisst jetzt ytdBis statt yearEnd -- ein Name, der "Jahresende" sagt
und "heute" bedeutet, waere die naechste Fundstelle gewesen.
2026-09-29 20:05:55 +02:00
cf5ed5c6c6 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.
2026-09-29 19:47:48 +02:00
a0b9f6dc80 Anker aus der laufenden Definition, und ein Test, der sich selbst durchwinkt
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Zwei Fehler aus dem ersten Lauf des Rauchtests, beide von mir.

Der Anker fuer transfer_employee stammte aus 20260727120200 und fand nichts:
die Funktion ist seither dynamisch gepatcht worden, die Belegungsabfrage ist
zweizeilig und beruecksichtigt den Stichtag. Dieselbe Falle wie immer, nur
diesmal nicht im Rumpf einer Funktion, sondern im Anker auf sie -- fuer
hire_employee und rehire_employee stimmten die Anker, weil sie aus der zuletzt
erzeugten Fassung kamen. Die Migration war nie angewendet (sie bricht als
Ganzes ab), deshalb die Datei korrigiert statt eine neue geschrieben.

Und der Rauchtest hat sich an einer Stelle selbst durchgewunken: das `raise`
fuer den Fehlschlag stand innerhalb des Blocks mit exception-Zweig, wurde also
vom eigenen Handler gefangen -- und weil seine Meldung das Wort "vorgemerkte"
enthielt, bestand die Pruefung auf die erwartete Fehlermeldung. Der Schritt
galt als bestanden, obwohl die Migration gar nicht angewendet war. Jetzt merkt
sich der Block nur, ob es ging, und ausgewertet wird danach.
2026-09-29 19:24:58 +02:00
194b6d8916 Rauchtest: der Lebenszyklus, einmal wirklich ausgefuehrt -- und die fuenfte Fundstelle
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt, und keine Pruefung
konnte das bemerken: die Selbstpruefungen der Migrationen lesen den Text der
Funktionen, die Unit-Tests laufen ohne Datenbank, und die Integrationstests
haben keinen Bestand, gegen den sie liefen. db/tests/rauchtest.sql schliesst
die Luecke -- es ruft die Funktionen auf, gegen die echte Datenbank, in der
Reihenfolge des Lebenszyklus: anlegen, versetzen, befoerdern, vormerken,
austreten, wiedereintreten.

Zwei Eigenschaften, ohne die es gefaehrlich waere. Es laeuft als
Anwendungsrolle statt als Superuser -- sonst bewiese es nur, dass die
Funktionen fuer niemanden gehen, der sie benutzt. Und es rollt am Ende zurueck,
mit einer Zaehlung danach, die das belegt: beim Test vom 29.09. sind reale
Personen auf Testplanstellen umgezogen und dort geblieben.

Beim Schreiben fiel die fuenfte Stelle mit dem Paar aus Schliessen und
Einfuegen auf: terminate_employee. Wer heute eingestellt und heute wieder
ausgetragen wird -- ohne den Grund "No Show" --, lief in chk_assignment_range.
Die No-Show-Haelfte derselben Funktion macht es laengst richtig und erklaert
auch, warum; es galt nur fuer genau einen Austrittsgrund.
2026-09-29 19:17:33 +02:00
89ba34c901 Vorgemerkte Planstellen, Stichtag fuer Einheiten, Kuendigungsschutz und Elternteilzeit
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
F.24 -- eine Planstelle, auf die eine Versetzung vorgemerkt ist, galt als frei.
Die Vormerkung steht in pending_org_changes, "besetzt?" wurde allein an
position_assignments gefragt. Auffallen wuerde es erst in der Nacht des
Wirksamkeitstages: dort legt der Nachtlauf die zweite Besetzung an und laeuft in
den Teilindex, der genau eine laufende Besetzung je Planstelle zulaesst. Er
arbeitet in einer Transaktion ueber alle faelligen Vorgaenge -- eine einzige
solche Buchung haette ihn vollstaendig zum Stehen gebracht, auch fuer alle
anderen. Neue Auskunft planstelle_vorgemerkt, abgefragt in hire_employee,
rehire_employee, transfer_employee und promote_employee, mit Datum in der
Meldung. Die eigene Vormerkung zaehlt nicht als Hindernis.

D.06 -- die Struktursicht zeigte Einheiten, die es am gewaehlten Stichtag noch
nicht gab: org_units wurde ungefiltert gelesen, waehrend Planstellen und
Besetzungen daneben auf den Stichtag eingeschraenkt waren. Die Druckansicht
filterte schon immer richtig; jetzt tun es alle drei Stellen.

H.09 -- der Kuendigungsschutz stand in der Akte nur als "bis TT.MM.JJJJ",
obwohl Personenkreis, Beginn, Ende und die ganze Begunstigung erfasst werden.
Der Personenkreis ist die eigentliche Auskunft. Die Behinderung bekommt eine
eigene Zeile: sie fuehrt oft zu Kuendigungsschutz, ist aber ein eigener
Bescheid.

Elternteilzeit und Wiedereingliederungsteilzeit sind jetzt auch bei der
Stundenaenderung waehlbar. Sie standen nur bei der Rueckkehr aus einer
Abwesenheit, mit der Begruendung, dass sie typischerweise dann beginnen --
typischerweise ist aber nicht immer. Die Ueberschneidung der beiden Listen ist
damit gewollt; der Test prueft nicht mehr auf Partition, sondern darauf, dass
keine Teilzeit an keinem der beiden Wege haengt.
2026-09-29 19:10:31 +02:00
990ebdbc84 Versetzung und Befoerderung am Tag, an dem die Besetzung begann
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m24s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Wer heute schon einmal versetzt wurde, liess sich heute nicht noch einmal
versetzen oder befoerdern: die laufende Besetzung beginnt dann heute, und der
Code schliesst sie auf denselben Tag. chk_assignment_range verlangt aber
valid_to > valid_from -- ein Intervall von null Tagen ist verboten, und der
Vorgang brach mit der rohen Datenbankmeldung ab.

Das Verbot ist richtig, und der Fall ist im Haus schon einmal entschieden
worden: terminate_employee loescht die Besetzung beim Grund "No Show", statt
sie auf [Eintritt, Eintritt) zu schliessen -- niemand hatte sie je inne.
Dieselbe Begruendung gilt hier. Die zweite Versetzung am selben Tag
abzuweisen waere genau der haeufigste Fall gewesen: jemand hat die falsche
Planstelle erwischt und will es sofort richtigstellen. Beide Ereignisse
bleiben in der Historie stehen.

Vier Stellen mit demselben Paar aus Schliessen und Einfuegen: transfer_employee,
promote_employee und im Nachtlauf die Zweige transfer und promotion. Alle vier
aus der laufenden Definition gelesen und an einem Anker ergaenzt; die
Selbstpruefung zaehlt die beiden Stellen im Nachtlauf, statt nur ihr
Vorhandensein zu pruefen -- der Zweig transfer ist dort schon einmal spurlos
verschwunden.
2026-09-29 18:57:56 +02:00
be9ca4758f Wiedereintritt wieder moeglich, und die Panels lesen die Akte neu
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Zwei Befunde aus dem Test vom 29.09., beide Klasse A.

K.01 -- rehire_employee scheitert bei jedem Aufruf: der case-Ausdruck fuer den
Status liefert text, die Spalte ist ein Aufzaehlungstyp. Genau das wurde am
10.08. schon einmal behoben. Vier spaetere Migrationen haben die Funktion neu
erzeugt und den Zusatz nicht mitgenommen -- zwei davon aus einer aelteren
Datei, zwei aus der laufenden Definition. Daraus die Lehre, die vorher nicht
dastand: aus dem laufenden Stand zu erzeugen schuetzt davor, Verhalten zu
verlieren, nicht davor, einen bereits vorhandenen Fehler mitzunehmen. Die
Selbstpruefung benennt deshalb jetzt das Erwartete und nicht nur das Neue.

H.05 -- "Daten aendern" schrieb veraltete Werte zurueck. Die Panels bleiben
eingebunden, damit ihr Ein- und Ausfahren laufen kann, belegen ihre Felder aber
mit useState(employee.…) vor -- und das laeuft nur beim ersten Aufbau. Nach
einer Befoerderung brachte router.refresh() die frische Akte herein, der
Zustand im Panel blieb der von vorhin, und beim naechsten Speichern ging er als
Ganzes an change_employee_data: der eben gesetzte Hay-Grade stand wieder auf
dem alten Wert. Jedes Panel bekommt jetzt einen key aus employees.updated_at,
der sich bei jeder Aenderung an der Zeile bewegt und sonst nie. Der
Wiedereintritts-Assistent daneben macht es seit jeher so.
2026-09-29 18:42:30 +02:00
3664b9a435 Neuer Slogan, und Hay-Grades stehen als Leiter statt nach Haeufigkeit
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m48s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Auf der Anmeldeseite steht jetzt "Alles im Blick. Alles Manner." statt des
bisherigen Satzes.

Die Berichte sortieren Gruppen nach der Kennzahl, gross zuerst. Bei Bereich
oder Standort ist das die Antwort auf die Frage, die der Bericht stellt. Bei
einer Leiter ist es keine: HG09 vor HG10 ist die Reihenfolge, in der die Werte
sind, und nach Haeufigkeit umgestellt liest sie sich als Zufall.

Die Sonderbehandlung, die es fuer Wochentage schon gab, ist dafuer zu einer
Liste "Dimensionen mit eigener Reihenfolge" verallgemeinert -- Wochentage und
Hay-Grades stehen darin, und die naechste Leiter ist ein Eintrag statt einer
dritten Verzweigung. Gilt damit auch fuer die Aufschluesselung innerhalb einer
Gruppe und fuer die Spalten im Berichtsexport, die dieselbe Funktion nutzen.

Der Generic Grade steht vor HG09: er ist keine Stufe, sondern ihr Fehlen, und
vor der niedrigsten faellt das am wenigsten als Aussage auf.
2026-09-29 18:26:38 +02:00
2eda038c2e Kontierung auch bei besetzten Planstellen -- aus dem Organigramm heraus
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Die Kostenstelle liess sich an genau einer Stelle aendern: auf der Seite
Positionen, ueber die Karte einer Vakanz. Diese Seite zeigt aber nur unbesetzte
und kuenftige Planstellen. Bei einer besetzten war die Kontierung damit nirgends
zu erreichen -- im Bestand betrifft das 784 von 788, und dasselbe galt fuer ihre
Taetigkeit.

Die Angaben zu einer Einheit fuehren jetzt ihre Planstellen auf, besetzte
eingeschlossen, mit Inhaber:in und Kostenstelle. Ein Klick oeffnet denselben
Dialog wie auf Positionen, kein zweiter daneben. Was sich aendern laesst,
entscheidet weiterhin die Datenbank: bei einer besetzten Stelle bleiben Einheit
und Gueltigkeitsende gesperrt, weil ein Abteilungswechsel ueber eine Versetzung
gehoert und nicht ueber die Stelle.

Geladen wird erst beim Oeffnen und nur fuer diese eine Einheit. Alle 788
Planstellen in jede Zeichnung des Organigramms zu legen hiesse, fuer achtzig
Einheiten zu laden, was man fuer eine braucht.

EditPositionModal verlangt dafuer nicht mehr eine OpenPositionResolved, sondern
nur noch die Felder, die er tatsaechlich liest. Die alte Signatur war der
Grund, warum er nur dort aufzumachen war, wo unbesetzte Stellen entstehen.
2026-09-28 21:52:01 +02:00
ab50c21b8a Klick auf eine Einheit zeigt ihre Angaben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m56s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m7s
Die naheliegendste Geste in dieser Ansicht war ohne Wirkung: eine Karte im
Organigramm liess sich anklicken und antwortete nicht. Jetzt oeffnet sie eine
Leiste mit dem, was an der Einheit steht -- Orgnummer, Art, die Kette nach
oben, Gueltigkeit, Leitung, Belegschaft, unbesetzte Planstellen, die direkt
untergeordneten Einheiten und die Zahl der Personen im ganzen Teilbaum. Dazu
dieselben zwei Handlungen wie auf der Karte, nur beschriftet statt als Symbol.

Alles daraus stammt aus den Daten, die die Struktursicht ohnehin geladen hat.
Kein zusaetzlicher Gang zur Datenbank und damit auch keine zweite Wahrheit --
was die Leiste zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.

Die Liste klickt sich genauso: beide Ansichten kommen aus einem Baum, und eine
Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie.

org_units liefert dafuer zusaetzlich valid_from und valid_to, aber nur an die
Struktursicht. Der Druck kommt ohne sie aus; auf OrgUnitNode stehen sie deshalb
als optionale Felder und nicht als zwei weitere Spalten, die ueberall
mitgeschleppt werden.
2026-09-28 21:27:36 +02:00
a3dde61ec1 Eine leere Organisationseinheit wieder entfernen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Der Papierkorb neben dem Plus, im Organigramm wie in der Liste, und nur dort,
wo nichts darunter haengt. Ein Knopf, der beim Klick eine Absage erteilt, ist
schlechter als kein Knopf -- die Datenbank prueft es trotzdem, denn sie sieht
auch geschlossene Planstellen, die im Baum gar nicht gezeichnet sind.

Abgewiesen wird mit Grund: untergeordnete Einheiten, Planstellen (geschlossene
zaehlen mit), Kostenstellen, die darauf verweisen, und die Wurzel selbst.

Geloescht und nicht geschlossen, und das ist eine bewusste Einschraenkung.
delete_position macht seit dem OM-Umbau den Unterschied vor: besetzt gewesen →
valid_to, nie besetzt gewesen → weg. Fuer Einheiten laesst sich davon heute nur
die zweite Haelfte umsetzen, weil ein valid_to an einer Einheit zwar
eingetragen, aber nirgends gelesen wuerde -- weder Organigramm noch
orgMapsAbfragen, Berichte, Druck oder die Auswahl beim Anlegen einer Planstelle
schraenken org_units auf den Stichtag ein. Die geschlossene Einheit staende
ueberall weiter da, nur mit einem Datum, das niemand sieht. Das Schliessen zum
Stichtag kommt, wenn org_units gegen den Stichtag gelesen wird -- dieselbe
Arbeit, die auch das Verschieben braucht.
2026-09-28 16:51:26 +02:00
a6b6a7d67c Eine Organisationseinheit aus dem Organigramm heraus anlegen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m22s
Bisher gab es dafuer nur den Import. Jetzt sitzt auf jeder Einheit im
Organigramm -- in der Grafik wie in der Liste -- ein Plus, das eine
untergeordnete Einheit anlegt, wahlweise gleich mit Leitungsplanstelle.

Bewusst nur dieser eine Fall. Umbenennen, verschieben und schliessen fehlen
nicht aus Zeitmangel: org_units.parent_id traegt kein Datum, ein Verschieben
aenderte damit auch jede Auswertung auf einen vergangenen Stichtag, und der
fruehere Stand waere danach nirgends mehr ablesbar. Anlegen stellt diese Frage
nicht, weil vorher nichts da war -- und es kann dabei auch kein Kreis
entstehen, was hier mehr wiegt als es klingt: die rekursive Abfrage in
om_reporting_lines hat weder Tiefenbegrenzung noch Kreiserkennung.

Die Leitungsplanstelle entsteht ueber create_position statt durch eine zweite
Fassung derselben Logik -- dort haengen Jobkatalog, Nummernvergabe und die
Pruefung "je Einheit genau eine Leitung".

Die Orgnummer wird eingetragen, nicht vergeben. Vorgeschlagen wird die naechste
freie Nummer der bestehenden Reihe, und nur dann, wenn sich im Bestand genau
eine Systematik ablesen laesst -- eine plausibel aussehende, aber erfundene
Nummer prueft niemand nach.

Das Datum kommt nicht aus dem Stichtag der Ansicht. Wer sich die Struktur zum
letzten Jahresende ansieht und auf Plus drueckt, will in aller Regel eine
Einheit von heute anlegen.
2026-09-28 16:28:33 +02:00
8b7e32f14a Hay-Grade auch in "Daten aendern"
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m35s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
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.
2026-09-28 15:06:58 +02:00
771020af72 LOLYO-Export: typografische Zeichen auf ASCII
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m21s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Der Gedankenstrich kam im Zielsystem als "–" an -- seine UTF-8-Bytes, gelesen
als Windows-1252. Nachgemessen hat die Vorlage des Anbieters dieselbe Kodierung
wie unsere Datei: UTF-8 ohne BOM, zu sehen an dem "bestätigt" in ihrer
Kopfzeile. Die Ursache liegt also nicht in dem, was wir schreiben, und solange
sie nicht geklaert ist, geht der Weg ueber das Zeichen selbst: ein Bindestrich
sagt dasselbe und ist in beiden Kodierungen dasselbe Byte.

Gilt fuer jede Zelle aus den Daten, nicht nur fuer die Position -- aufgefallen
ist es dort, weil die Taetigkeiten einen Gedankenstrich fuehren, aber dieselben
Zeichen stehen auch in Namen und Titeln.

Zwei Ausnahmen: Umlaute bleiben, weil "Baeckerei" ein Problem verstecken wuerde,
das dann auch Namen betraefe. Und das Passwort bleibt Zeichen fuer Zeichen, wie
es ist -- ein ersetztes Zeichen faellt niemandem auf, es gibt dann nur ein
Konto, in das niemand hineinkommt.
2026-09-28 14:05:41 +02:00
8ac4df17c1 Position im Export: die Taetigkeit der Planstelle, nicht die Spalte an der Person
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Die Berichtsseite loest die Position ueber die Besetzung auf -- die Taetigkeit
der Planstelle, auf der die Person am Stichtag sitzt, und nur ersatzweise
employees.job_title. Die Exporte nahmen nur die Spalte. Beides sind
unterschiedliche Angaben, also konnte in der Akte etwas anderes stehen als in
der Datei daneben, ohne dass eines von beidem falsch aussah.

Betrifft die Spalte "Position" im vollstaendigen Datenexport und im
LOLYO-Export.
2026-09-28 13:54:56 +02:00
38e35e2ff9 Kein Formelschutz in einer Datei, die eine Maschine einliest
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Das vorangestellte Hochkomma schuetzt einen Menschen, der eine CSV in Excel
oeffnet: ein Wert mit fuehrendem =, +, - oder @ waere dort sonst eine Formel.
In einer Load-Datei ist es das Gegenteil von Schutz. Jede oesterreichische
Telefonnummer beginnt mit "+", und in LOLYO stuende danach in jedem Konto
'+4366... als Nummer -- ohne dass es beim Erzeugen jemandem auffiele.

toCsv bekommt dafuer formelschutz in der CsvForm, voreingestellt an. Nur LOLYO
schaltet es ab. Die Telefonspalten von Cornerstone sind bisher leer, deshalb
bleibt die Datei dort, wie sie ist.
2026-09-28 13:47:49 +02:00
676cecbf15 Export fuer LOLYO, und die Zielsystem-Exporte in einem eigenen Abschnitt
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
LOLYO ist die dritte Datei, die in ein fremdes System geladen wird. Spalten,
Trennzeichen und Schreibweise kommen aus der Vorlage des Anbieters, Zeichen fuer
Zeichen -- einschliesslich der gemischten Kopfzeile ("Titel prefix" deutsch,
"Title Suffix" englisch). Semikolon und kein BOM: das BOM haenge unsichtbar am
Namen der ersten Spalte, und "Benutzer/Code" kaeme in keiner Zuordnung mehr vor.

Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
Kennung, die wir treffen muessten.

Diese Datei legt Konten an, samt Passwort in der Zeile. Das Startpasswort steht
deshalb in der Route und nicht in lib/lolyo.ts: eine Route ist serverseitig, eine
lib-Datei waere nur so lange sicher, wie niemand sie aus einem Client-Bauteil
heraus einbindet -- und das saehe man dem Buendel nicht an. Dieselbe Ueberlegung
steht in lib/passwort.ts.

Die drei Zielsystem-Exporte stehen jetzt unter einer gemeinsamen Ueberschrift,
getrennt vom Datenexport darueber. Der gibt Zahlen heraus, die jemand ansieht;
diese legen im Zielsystem Datensaetze an. Nebeneinander in einer Reihe
gleichartiger Karten war das nicht zu sehen. Die Auswahl steht damit einmal
statt dreimal da.

Der erklaerende Text unter dem Cornerstone-Export entfaellt auf Wunsch des
Kunden.
2026-09-28 13:45:38 +02:00
af2ad53ef9 Die Exportkarten nennen die ganze Auswahl, nicht nur den Status
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m14s
Das Einschraenken der Downstream-Exporte gab es schon: exportHref reicht die
ganze Adresszeile weiter, und ladeExportMitarbeiter wendet dieselben Kriterien
an wie der Bericht daneben -- Eintritt ab, Austritt bis, Wochenstunden,
Beschaeftigungsart und die uebrigen. Nur sah man es den Karten nicht an. Dort
stand ausschliesslich der Status; alles andere steckt eingeklappt in "Weitere
Kriterien" weiter oben.

Damit sah eine Datei mit 40 Personen genauso aus wie eine mit 785. Fuer einen
Bericht ist das aergerlich, fuer ein Load-File in ein Zielsystem ist es ein
Datenstand, den dort niemand mehr hinterfragt: es fehlen Personen, und es sieht
nicht nach einem Filter aus.

Die drei Karten im Bestandsmodus zeigen jetzt Stichtag, Status, Einheit,
Standort, jedes gesetzte Kriterium und die Zeilenzahl. beschreibeKriterien
steht neben anzahlKriterien in derselben Datei und liest dieselben Listen -- ein
neues Kriterium erscheint damit von selbst auch auf den Karten. Ein Test haelt
beide gegeneinander, damit die Zahl am Aufklapper und der Text auf der Karte
nicht auseinanderlaufen.
2026-09-28 12:25:17 +02:00
677ca35df2 Manager ID verweist ueber die Cornerstone-ID, nicht ueber die Personalnummer
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m53s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
Im Feld Manager stand die Personalnummer der vorgesetzten Person -- also der
Wert, der in derselben Zeile als Local System ID gefuehrt wird. Cornerstone
verknuepft aber ueber die User ID. Der Verweis lief damit entweder ins Leere
oder, schlimmer, auf jemand anderen, dessen User ID zufaellig aussieht wie
eine Personalnummer.

Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID traegt. Aus
demselben Grund wie bei User ID und Username: eine Kennung der falschen Art
ist schlimmer als keine, weil niemand ihr ansieht, dass sie falsch ist. Bei
435 von 785 Personen fehlt die Kennung noch -- das ist eine Luecke in der
Zuordnungstabelle, kein Fehler im Export.

Die Abbildung im Kontext heisst entsprechend managerKennung und traegt jetzt
Zeichenketten, damit der Typ selbst keine Nummer mehr zulaesst.
2026-09-28 11:13:55 +02:00
779beb4478 Hay-Grade statt Verwendungsgruppe A--F
A--F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene Stufen
mit erfundenen Beschreibungen. Der Kunde bewertet nach Hay und hat die Liste
geschickt -- 13 Stufen plus einen Generic Grade.

Dieselbe Spalte fuellt im Cornerstone-Extrakt die Grade ID. Solange dort A--F
steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
auffaellt: das Zielsystem kennt diese Kennungen nicht.

Der Typ heisst weiter paygrade_type, ist aber jetzt eine Domain ueber text mit
CHECK statt eines Aufzaehlungstyps. Ein Enum laesst sich nicht umschreiben --
Werte entfernen geht gar nicht, und add value darf im selben Vorgang, der den
neuen Wert schreibt, nicht benutzt werden. Wichtiger: die rund zehn
SQL-Funktionen, die den Wert nach paygrade_type umwandeln, bleiben unveraendert
gueltig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
haben, aus einer veralteten Vorlage zu kopieren.

Zwei Funktionen muessen doch angefasst werden, beide per Punktaenderung an der
laufenden Definition statt per Kopie aus einer Datei: der Vorgabewert B in
hire_employee und die Beschriftung im Protokoll von promote_employee.

Der Bestand bekommt den Generic Grade. Aus A--F liesse sich kein Hay-Grade
ableiten: andere Einteilung, andere Anzahl. Geraten saehe im Extrakt genauso
aus wie erhoben.
2026-09-28 11:13:41 +02:00
c3e19606e2 Die Cornerstone-ID als eigenes, freiwilliges Feld
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m58s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m24s
Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export
dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID --
richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter
der Cornerstone die Person kennt. Jetzt steht dort diese Spalte.

Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784
Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer
Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen
Spalte verlorengingen.

Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein
Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne
dass es auffiele, und legte in Cornerstone eine zweite Person neben der
bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen
eigenen Test.

Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei
der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den
Schritt "Person", change_employee_data macht das Feld aenderbar,
apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung
nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie
richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben,
und prueft jede der fuenf einzeln.

Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
2026-09-28 10:13:52 +02:00
6717412c9f User ID and Username carry the Alpenwerk UUID
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m2s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
Both columns now hold employees.id, and they hold the same value: in
Alpenwerk the user name and the user id never diverge. If they did,
Cornerstone would show two identities for one person and every
reference to them -- manager, reports -- could hit the wrong one.

The UUID rather than the personnel number, because it is the identifier
that never changes. A personnel number can be corrected, and the person
would then be somebody else in Cornerstone.

Local System ID keeps the personnel number as LOGA assigns it, which is
what that column is for. Customfield ID AD keeps the directory account
name (vorname.nachname); it is a separate thing from the Cornerstone
user name and always was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:25:41 +02:00
410acfe01f Cornerstone Report: system values, not display names
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m34s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Reworked against the load spec and the 27.09. test file. The values I
had guessed were the German display names, which is the first entry on
the list of errors from earlier loads: Cornerstone answers "ungültiger
Wert" and rejects the whole row, followed by "Alle abhängigen Felder
müssen gültig sein" as a follow-on.

  Status            Aktiv/Inaktiv    -> Active/Inactive
  Employment Status Arbeitend/...    -> Working/On Leave/Terminated
  User Type         Mitarbeiter      -> Employee
  Time Zone         CET              -> empty

The time zone is the second entry on that list: only a portal time zone
id is valid, an abbreviation gives "Zeitzonencode nicht eindeutig".
Empty means the portal or the OU decides.

Division ID is the GUID from the test file, not a name. Termination
fields and Leave Reason are filled only when the employment status
carries them -- a reason without a termination is an invalid state for
the load, not extra information.

Four fields now stay empty on purpose, because filling them would mean
inventing an identifier that belongs to the target system: Location ID
(locations has id/name/country and no Cornerstone id), Position ID (our
S-0001 is not a Cornerstone position), Months of Service (Cornerstone
derives it) and Rehired Employee (the value for "yes" is unconfirmed,
and an unconfirmed value costs the whole row). Retention Rules and
Organisationsstufe stay empty because the load ignores them.

Header and row now match the test file byte for byte, except User ID
(no TEST- prefix outside a test load), Required Training Approvals and
Location ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:10:56 +02:00
478364e2d0 Add the Cornerstone export to Berichte
The 57 columns of the Cornerstone user import, in the given order and
spelling, offered as CSV and Excel under the Honestly report. Same
people and same filters as the full export.

Two things about the file itself, both of which would have failed
quietly. Cornerstone reads comma-separated, while toCsv writes the
semicolons and the BOM that German Excel wants -- the BOM would have
hidden inside the name of the first column, so "User ID" would have
matched nothing in the mapping. toCsv now takes the form as an argument
and keeps its old defaults; the Cornerstone form lives next to the
columns so a test can hold both. Quoting follows the delimiter now,
otherwise a comma in an address would split the row.

Dates go out day-first, matching the import setting. An ISO date is
read as a different, equally valid date and nobody notices.

Gender maps to Cornerstone's own values; anything unexpected becomes
"not specified" rather than empty, because an invalid value makes
Cornerstone reject the whole row, not just the field.

Status and Employment Status come from separate rules: somebody on
Karenz has a working account and is not working, and filling both from
one value gets one of the two wrong.

Four fields carry visible placeholders because the leading systems do
not supply their identifiers yet: Division ID, Doxis, Interflex,
LGVplus. Home Phone and Personal Email stay empty on purpose -- what
leaves the house is the business data.

Not verified against a running Cornerstone import, and not seen in a
browser; there is no database reachable here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 11:21:25 +02:00
e11544b8da Die Messung wieder entfernen -- die Ursache steht fest
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
Die Akte oeffnet jetzt in rund 100 ms statt in 1,5 s:

  vorher   person 4ms, hauptabfrage 1554ms, nachschlag 11ms, gesamt 1569ms
  nachher  person 2ms, hauptabfrage  79ms, nachschlag  7ms, gesamt   87ms

Gefunden hat das der vorlaeufige console.info in dieser Datei, nicht das
Nachdenken darueber. Behoben haben es zwei Migrationen: die Policies
pruefen die Rechte einmal je Abfrage (20260924200000), und
om_reporting_lines laeuft als security definer mit require_hr_admin in
der ersten Zeile (20260924220000).

Nebenbei die Behauptung im Kopf der Datei richtiggestellt: dort standen
"rund 36 ms Umlaufzeit" als Begruendung dafuer, die Zahl der Rundreisen
zu druecken. Nachgemessen sind es 0,5 ms. Die Rundreisen waren nie das
Problem.
2026-09-24 12:39:50 +02:00
520242e3c3 Die Selbstpruefung ruft die Funktion nicht mehr auf
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Die Migration ist an ihrer eigenen Pruefung gescheitert:

  FEHLGESCHLAGEN 20260924220000_reporting_lines_ohne_zeilenschutz.sql
  Nicht berechtigt: nur aktive HR-Benutzer:innen duerfen diese Aktion
  ausfuehren.

Die Pruefung verglich die Zeilenzahl der neuen Funktion mit der Zahl der
laufenden Besetzungen -- und rief sie dafuer auf. Die Funktion verlangt
seit dieser Migration HR-Rechte, die Migration selbst laeuft aber als
Administrator ohne app.user_id. require_hr_admin() hat sie also zu Recht
abgewiesen.

Nichts wurde eingespielt, die Transaktion ist vollstaendig
zurueckgerollt.

Jetzt wird der Koerper gelesen statt ausgefuehrt: geprueft wird, dass
require_hr_admin, security definer und der search_path stehen und dass
die Bestandteile der Abfrage vollzaehlig uebertragen sind. Eine
Selbstpruefung darf nichts aufrufen, was einen angemeldeten
Anwendungsbenutzer voraussetzt.
2026-09-24 12:33:19 +02:00
6d006e55fe om_reporting_lines prueft die Rechte einmal statt je Zeile
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m10s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m29s
Die Akte brauchte 1,3 bis 1,5 s, davon ueber 1,1 s in dieser Funktion.
Als postgres -- am Zeilenschutz vorbei -- kostet dieselbe Funktion 60 ms.

  als postgres                             60 ms
  als alpenwerk_app, vor 20260924200000  1457 ms
  als alpenwerk_app, danach              1153 ms

Die vorige Migration fasste die Policies so, dass is_hr_user() je Abfrage
einmal laeuft. Das half um ein Fuenftel -- die Pruefung war also nicht der
Hauptposten.

Die Teile der Funktion sind einzeln billig, unter Zeilenschutz gemessen:
Besetzungen 12 ms, Vorfahrenkette 7 ms, Leitungen 9 ms. Zusammen 28 ms,
die Funktion daraus 1153. Die Kosten entstehen beim Zusammensetzen: unter
Zeilenschutz wird aus jeder Tabellenreferenz eine Unterabfrage mit
Sicherheitsschranke, und der Planer verliert die Freiheit, die
Zwischenmengen einmal zu berechnen -- die beiden Unterabfragen am Ende
laufen dann fuer jede der 784 Zeilen erneut.

Jetzt security definer mit require_hr_admin() in der ersten Zeile. Das ist
dieselbe Bedingung, die in den Policies aller vier gelesenen Tabellen
steht -- require_hr_admin() ist woertlich "if not is_hr_user() then
raise". Sie wird einmal gestellt statt hunderttausendfach. Wer Daten
bekommt, aendert sich nicht; eine unberechtigte Anfrage bekommt jetzt eine
Meldung statt einer leeren Antwort.

Der Preis: diese Funktion liest die vier Tabellen am Zeilenschutz vorbei.
Kaeme je eine feinere Rechteordnung, muesste sie eigens nachgezogen
werden. Mit dem Eigentuemer besprochen -- solche Rollen sind nicht
vorgesehen.

Der Koerper ist unveraendert aus 20260727120100, der einzigen textlichen
Fassung. Geaendert sind Sprache, security definer, search_path und die
erste Zeile.
2026-09-24 12:13:42 +02:00
a1b4674a22 Die Rechtepruefung in den Policies einmal je Abfrage statt je Zeile
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m10s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Die Personalakte brauchte beim Oeffnen anderthalb Sekunden. Im Container
gemessen: person 4ms, hauptabfrage 1554ms, nachschlag 11ms. In der
Hauptabfrage steckt om_reporting_lines -- als postgres 60 ms, als
alpenwerk_app 1457 ms. Derselbe Aufschlag liegt auf allem: ein blosses
count(*) kostet unter RLS 44 bis 55 ms statt 7.

Ursache: ein Policy-Ausdruck wird je Zeile ausgewertet. `is_hr_user()` ist
zwar stable, aber das erlaubt Postgres nur, den Wert innerhalb einer
Anweisung als unveraenderlich anzusehen -- nicht, ihn aus dem Zeilenfilter
zu ziehen. Bei jeder Zeile laufen zwei Unterabfragen auf profiles und
app_passwoerter.

In einen Unterausdruck gefasst wird daraus ein InitPlan, einmal
ausgewertet. Ohne RLS gemessen, damit nur dieser Unterschied sichtbar ist:

  om_positions where is_hr_user()           14,8 ms
  om_positions where (select is_hr_user())   0,5 ms
  employees    where is_hr_user()            4,1 ms
  employees    where (select is_hr_user())   0,5 ms

Die Zugriffsentscheidung aendert sich nicht: dieselbe Funktion, dasselbe
Ergebnis, nur nicht mehr achthundertmal gefragt. Keine Tabelle verliert
ihre Policy, keine Rolle bekommt Rechte dazu, alpenwerk_app bleibt ohne
BYPASSRLS.

Der andere Weg -- om_reporting_lines auf security definer umstellen --
waere genau das nicht: er verschoebe die Grenze, um schneller zu werden,
und haelfe nur dieser einen Funktion.

Rund zwanzig Policies von Hand abzuschreiben hiesse, eine davon falsch zu
treffen und eine Tabelle zu oeffnen oder zu schliessen, ohne dass es
auffaellt. Deshalb werden die bestehenden Definitionen aus pg_policies
gelesen und unveraendert wieder angelegt; ersetzt wird allein der
Funktionsaufruf. Die Selbstpruefung zaehlt nach, dass keine offen blieb
und keine verlorenging.
2026-09-24 12:01:58 +02:00
c8f9ae8047 Vorübergehend messen, woher die anderthalb Sekunden der Akte kommen
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has started running
Die Akte braucht beim Oeffnen rund 1,5 s. Die Datenbank ist es dem
Anschein nach nicht: eine Rundreise kostet 0,5 ms (nicht 36, wie der
Kommentar hier behauptete), und om_reporting_lines misst 60 ms. Damit
laegen unter zehn Prozent der Zeit in den Abfragen.

Statt weiter zu raten -- heute schon zweimal danebengelegen -- schreibt
loadEmployeeDetail eine Zeile ins Protokoll des Containers: Zeit fuer die
Person, fuer die Hauptabfrage, fuer die Nachschlaege, insgesamt, und wie
viele Berichtslinien uebertragen wurden.

Wieder entfernen, sobald die Ursache feststeht.
2026-09-24 11:52:36 +02:00
499d400fe1 Die Berichtslinie einmal ausrechnen statt zweimal
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m7s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Die Akte holte Vorgesetzte und direkte Berichte in zwei Teilabfragen, je
eine mit eigenem Filter -- in der Annahme, der Filter schraenke die
Funktion ein. Tut er nicht. om_reporting_lines traegt ein `with
recursive` im Koerper und laesst sich deshalb nicht einbetten; sie rechnet
jedes Mal alle Zeilen aus, und der Filter wirft sie danach weg.

Auf dem Server gemessen:

  explain analyze select * from om_reporting_lines(current_date)
    where employee_id = ...;
  -> Rows Removed by Filter: 783, Execution Time: 69 ms

Und ohne Filter dieselben 60 ms. Zwei Filter hiessen also zweimal
dieselbe Rechnung, rund 120 ms je Aufruf der Akte -- der groesste
Einzelposten der Seite.

Jetzt ein Aufruf, und die Auswahl trifft der Aufrufer. Dafuer wandern
einmal alle Zeilen herueber statt neun; ueber eine Verbindung im selben
Netz kostet das den Bruchteil dessen, was die zweite Rechnung kostete.

Der Kommentar, der das Gegenteil behauptete, ist durch die Messung
ersetzt.
2026-09-24 11:39:24 +02:00
e6b7defae2 Eine datierte Stammdatenaenderung verlor fuenf Felder
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
change_employee_data schreibt bei einem Stichtag in der Zukunft nicht in
die Zeile, sondern legt einen Eintrag in pending_org_changes an; am
Stichtag traegt ihn apply_due_pending_changes nach. Dessen Zweig
contract_change kannte fuenf Spalten nicht, die der sofortige Weg
schreibt: die drei Felder des Notfallkontakts und beide Titelfelder.

Wer also den Notfallkontakt oder einen akademischen Titel zu einem
kuenftigen Datum erfasste, sah den Vorgang in der Personalakte, und am
Stichtag geschah nichts. Der Eintrag wurde auf "applied" gesetzt, die
Zeile blieb unveraendert, eine Meldung gab es nicht.

Gefunden durch einen Abgleich beider Wege: 44 Spalten schreibt
change_employee_data sofort, 39 der Nachtlauf. Jetzt 44 zu 44. Dieselbe
Luecke wie bei der Gruppe `role` (20260915120000) und bei der Besetzung im
Zweig `transfer` (20260924100000) -- und derselbe Weg, sie zu finden.

Die Selbstpruefung nennt die fuenf Felder ausdruecklich und prueft
zusaetzlich weiter, was hier schon einmal verlorenging.
2026-09-23 20:34:40 +02:00
e5aa527473 Angehoerige berichtigen, und beide Abschnitte sehen gleich aus
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m26s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m15s
Angehoerige liessen sich bisher nur anlegen und entfernen. Ein Tippfehler
im Namen war nur zu beheben, indem man die Person loeschte und neu
anlegte -- zwei Eintraege in der Akte fuer eine Korrektur, und der erste
sagte "entfernt", was nicht stimmte. Jetzt steht in jeder Zeile ein Stift,
wie beim Notfallkontakt.

update_employee_dependent kommt bewusst **ohne** Stichtag. Hinzufuegen und
Entfernen tragen einen, dort passiert etwas zu einem Zeitpunkt. Eine
Berichtigung nicht: der Wert war schon vorher falsch, und ein "wirksam ab"
hiesse, die Person habe bis dahin anders geheissen. Der Eintrag in der
Akte nennt deshalb Vorher und Nachher statt eines Datums.

In "Daten aendern" standen fuer den Notfallkontakt drei Eingabefelder,
waehrend die Angehoerigen darueber als Tabelle mit Knoepfen erscheinen --
zwei Bauformen fuer denselben Zweck, direkt untereinander. Dort steht
jetzt derselbe Abschnitt wie im Reiter "Stammdaten"; beide laufen ueber
das "Wirksam ab" des Formulars, wie die Angehoerigen es schon taten.

Damit schreibt auch nur noch eine Stelle diese drei Spalten. Vorher
schickte das Formular sie zusaetzlich im eigenen Aufruf mit.
2026-09-23 20:03:43 +02:00
2bba1f40ac Die Migration gegen auth.uid() zuruecknehmen -- sie war falsch und gefaehrlich
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Die Annahme war, acht Funktionen riefen weiterhin auth.uid() und
scheiterten deshalb an den Rechten der Anwendungsrolle. In der Datenbank
ist das nicht so:

  select proname, prosecdef, pg_get_functiondef(oid) like '%auth.uid()%'
  -- alle acht: ruft_auth_uid = f

Der Grund: 20260805110000_replace_auth_uid_in_functions.sql liest die
Definition jeder Funktion, ersetzt den Text und legt sie neu an. Es gibt
rund ein Dutzend solcher Migrationen. Wer die *textliche* letzte Fassung
in den Migrationsdateien sucht, sieht diese Aenderungen nicht -- genau das
hatte ich getan.

Damit war die Migration nicht nur ueberfluessig, sondern schaedlich: sie
haette die acht Funktionen mit Koerpern aus alten Dateien ueberschrieben
und jeden danach aufgetragenen Patch stillschweigend entfernt. Also genau
der Fehler, gegen den sie sich richtete.

Sie wurde nie eingespielt. Die Lehre steht in CLAUDE.md: massgeblich ist
der Katalog der laufenden Datenbank, nicht die Summe der Dateien.
2026-09-23 19:53:19 +02:00
01c49ee56c Acht Funktionen riefen auth.uid() -- und scheiterten damit vollstaendig
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m11s
CI / Migrationen auf leerer Datenbank (push) Failing after 5m18s
Die Attrappe des Supabase-Schemas gibt der Anwendungsrolle mit Absicht
kein usage auf `auth`: "fiele je wieder ein Bezug auf dieses Schema in
den laufenden Betrieb, soll er scheitern und nicht stillschweigend
funktionieren." Er ist gefallen, und sie scheitern.

Auf dem Server nachgemessen, nicht vermutet:

  select has_schema_privilege('alpenwerk_app', 'auth', 'usage');  -- f
  set local role alpenwerk_app; select auth.uid();
  -- ERROR: permission denied for schema auth

Keine der acht ist security definer, sie laufen also mit den Rechten der
Anwendungsrolle. Betroffen war je eine vollstaendige Operation:
Versetzung, Planstelle anlegen, Planstelle loeschen, Angehoerige
hinzufuegen und entfernen, HR-Notiz anlegen und erledigen, Rueckkehrdatum
verschieben.

Der Umstieg auf app_current_user_id() geschah im August und danach
Funktion fuer Funktion, sobald eine ohnehin angefasst wurde. Diese acht
wurden seither nicht mehr angefasst, und nichts prueft den Rest: die
Migrationen laufen durch, die Tests kennen keine Datenbank, und wer die
Oberflaeche bedient, bekommt die Meldung erst beim Speichern.

Geaendert ist nur zweierlei je Funktion -- auth.uid() wird
app_current_user_id(), und der search_path steht fest. Die Koerper sind
unveraendert aus ihren letzten gueltigen Fassungen uebernommen.

Die Selbstpruefung geht das ganze Schema durch statt nur die acht: sie
schlaegt an, sobald irgendeine Funktion in public das Schema auth wieder
anfasst, und prueft zusaetzlich, dass das Recht entzogen bleibt.
2026-09-23 19:00:51 +02:00
37650ee9a4 Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m9s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.

Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.

Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch

    update employees set job_title = coalesce(payload->>'new_title', ...)

also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.

Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
2026-09-23 18:41:41 +02:00
93cb1c1699 Den Notfallkontakt wie die Angehoerigen darstellen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m39s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Beide Abschnitte stehen im selben Reiter und zeigen dasselbe: Personen im
Umfeld. Die Angehoerigen standen in einer Tabelle, der Notfallkontakt in
einer Beschreibungsliste -- zwei Baustile untereinander, ohne dass ein
Unterschied in der Sache dahintersteht.

Jetzt dieselbe Tabelle mit denselben Spaltenkoepfen, und Aendern und
Entfernen als Symbole in der Zeile, wie dort das Entfernen. Der Knopf
"+ Hinzufuegen" steht nur, solange kein Kontakt hinterlegt ist: es gibt
genau einen, und ein Knopf daneben verspraeche eine Liste. Die
Telefonnummer bleibt waehlbar.
2026-09-23 18:29:53 +02:00
db02fb4762 Elf Punkte aus der Rueckmeldung, und die verlorene Anmerkung
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m18s
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel
"Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte
(Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn.
"Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der
Behinderung und der Erklaertext ueber dem Honestly-Report sind weg.
"Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt
ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und
ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler.

Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine
Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern"
wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im
Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte
Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse.

"Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer
"Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt.
Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den
keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe
Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine
Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden
Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still
auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben
bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige.

Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends.
Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis
20260814100000 stand sie in der Beschreibung des Ereignisses; beim
Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration
fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht
jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung
faengt den naechsten Verlust ab.
2026-09-23 16:01:05 +02:00
5e35d6bf41 Eine geleerte freiwillige Angabe ist null, nicht die leere Zeichenkette
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m49s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Beim Speichern in "Daten aendern" brach es ab, sobald bei einer zweiten
Person das Feld "E-Mail (privat)" leer blieb:
duplicate key value violates unique constraint "employees_email_key".

email war bis 20260811140000 NOT NULL -- jede Person hatte eine, das Feld
war nie leer. Seither ist die Angabe freiwillig, aber
change_employee_data schrieb weiter `coalesce(v_person->>'email', email)`.
Ein leeres Formularfeld kommt als "" an, und "" ist nicht null: es landete
als leere Zeichenkette in der Spalte. Beim ersten Mal ging das gut, beim
zweiten schlug der eindeutige Index zu -- "" ist gleich "", waehrend null
nie gleich null ist.

Aufgefallen ist es erst jetzt, weil die Spieldaten durchweg Adressen
trugen. Die 784 uebernommenen Personen tragen keine.

Alle freiwilligen Textfelder bekommen deshalb dieselbe Form wie der
Notfallkontakt und die Firmen-E-Mail: `case when ? then nullif(…, '')`.
Fehlt der Schluessel, bleibt der alte Wert; steht er leer da, wird das
Feld geleert -- die Absicht, die jemand ausdrueckt, wenn er eine Angabe
herausloescht. first_name, last_name, gender, birth_date und nationality
bleiben ausgenommen, sie sind NOT NULL.

Der Nachtlauf bekommt dieselbe Behandlung, sonst liefe eine auf spaeter
datierte Aenderung in denselben Index -- um drei Uhr frueh und ohne
jemanden, dem die Meldung angezeigt wuerde. Was bereits als "" in der
Datenbank steht, raeumt die Migration auf; sonst blockierte diese eine
Zeile weiterhin jede weitere Person ohne Adresse.
2026-09-23 15:42:43 +02:00
948ddf5c70 Verhaeltnis als Auswahlliste, und im Organigramm keine Vakanz ganz oben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m41s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m16s
Zwei Nachtraege zur Rueckmeldung des Kunden.

Das Fenster fuer den Notfallkontakt hatte ein Freitextfeld fuer das
Verhaeltnis, waehrend der Einstellungsassistent und "Daten aendern"
dieselbe Auswahlliste fuehren (EMERGENCY_RELATIONS). Freitext hiesse,
dass "Gattin", "Ehefrau" und "Frau" nebeneinander stehen und sich nicht
auswerten lassen -- genau der Grund, aus dem die Liste ueberhaupt
existiert. Jetzt fuehren alle drei Stellen dieselbe.

Und "Leitung vakant" stand im Organigramm weiterhin bei der obersten
Einheit. Entfernt wurde es zuletzt nur im Druck; die Ansicht am
Bildschirm hat ihre eigene Beschriftung. Die Gesellschaft wird nicht
gefuehrt, sondern ist das Ganze -- der Vermerk las sich dort wie eine
offene Stelle und faerbte den obersten Knoten obendrein als Vakanz ein.
Ueberall sonst bleibt er.
2026-09-23 12:58:57 +02:00
57acbfae9d Die Firmen-E-Mail als eigenes, freiwilliges Feld
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.

Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.

Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
2026-09-23 10:52:26 +02:00
1816ab68ad Den Notfallkontakt an Ort und Stelle anlegen statt ueber "Daten aendern"
Bei den Angehoerigen steht ein "+ Hinzufuegen", beim Notfallkontakt stand
nichts: der einzige Weg zu einer Telefonnummer fuehrte ueber ein Formular
ueber saemtliche Stammdaten. Jetzt steht der Knopf an derselben Stelle,
mit einem Fenster fuer Name, Telefonnummer und Verhaeltnis; ist ein
Kontakt hinterlegt, heisst er "Aendern" und daneben laesst er sich
entfernen.

Geschrieben wird ueber dieselbe Funktion wie in "Daten aendern" --
change_employee_data mit nur den drei Feldern. Der Eintrag landet damit
in der Historie und im Protokoll wie jede andere Stammdatenaenderung. Die
Datenbank fasst nur die Felder an, die im Aufruf stehen: sie prueft auf
das Vorhandensein des Schluessels, nicht auf seinen Wert (Migration
20260917120000, Zeilen 209-211). Adresse, E-Mail und der Rest bleiben
unberuehrt.
2026-09-23 10:41:24 +02:00
1baf8c2867 Im Druck jeden untersten Bereich als Blatt, und die Zwischenebene zeichnen
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 12m9s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m41s
Die Blaetter kamen aus `root.children`, also aus den unmittelbaren Kindern
der Gesellschaft. Das stimmte, solange die Bereiche dort hingen. Beim
Kunden liegt "CEO" dazwischen und ist selbst ein Bereich: die Auswahl bot
einen einzigen Eintrag an, und aus 784 Personen wurde die Uebersicht plus
ein Blatt fuer CEO.

Genommen wird jetzt jeder Bereich, unter dem kein weiterer liegt -- nach
dem Etikett und nicht nach der Stellung im Baum. Nur nach dem Etikett zu
gehen genuegte nicht: CEO kaeme als eigenes Blatt dazu und enthielte die
sechs anderen noch einmal.

Die Uebersicht haengte die gewaehlten Bereiche unmittelbar unter die
Gesellschaft und haette CEO damit stillschweigend uebersprungen. Sie
zeichnet jetzt den echten Weg von oben, beschnitten auf die Aeste mit
einem gewaehlten Bereich, und geht so tief, wie dieser Baum reicht. Das
Ab- und Anwaehlen wirkt weiterhin bis in die Uebersicht.

Bei der Gesellschaft entfaellt "Leitung unbesetzt": sie wird nicht
gefuehrt, sondern ist das Ganze. Ueberall sonst bleibt der Vermerk.
2026-09-23 10:19:53 +02:00
33d4582b30 In der Mitarbeiterliste nur die eigene Einheit zeigen, nicht den Bereich
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m17s
Die Spalte fuehrte zwei Zeilen: den Bereich und darunter die Einheit der
Person. Beim Kunden heissen die Bereiche CEO, CFO, CSMO, CPO, COO, CHRO --
Rollenbezeichnungen, die ueber jedem Namen dasselbe wiederholten. Auf
seinen Wunsch bleibt nur die Einheit stehen; der ganze Weg von oben steht
weiterhin im title, denn eine Einheit wie "Shopleitung" sagt allein nicht,
welche gemeint ist.

Sortiert wird jetzt ebenfalls nach der Einheit. Bliebe der Bereich das
erste Kriterium, ordnete die Spalte nach einem Wert, den sie nicht mehr
anzeigt -- von aussen sieht das aus wie gar keine Sortierung. Der
rekursive Ausdruck aus 3e49be5 entfaellt damit; divisionOf bleibt in
Gebrauch, die Uebersicht gruppiert weiter nach Bereich.
2026-09-22 16:52:23 +02:00
d08e4f86fe Den Bereich einer Einheit ueber das Etikett suchen, nicht ueber die Stellung
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m34s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m17s
divisionOf nahm "die oberste Einheit unterhalb der Gesellschaft" -- eine
Aussage ueber die Stellung im Baum statt ueber unit_type. Im Altmodell
fiel beides zusammen, weil unter der Gesellschaft genau die Bereiche
hingen. Bei Manner liegt dort allein "CEO", darunter erst die sechs
C-Level-Bereiche: die Funktion gab fuer alle 784 Personen "CEO" zurueck.
In der Mitarbeiterliste stand es in jeder Zeile, auf der Uebersicht lag
die ganze Belegschaft im Balken "CEO", waehrend die sechs uebrigen
Bereiche auf null standen.

Gesucht wird jetzt der naechste Vorfahre mit unit_type = 'Bereich', die
Einheit selbst eingeschlossen. Der naechste und nicht der oberste: "CEO"
ist selbst ein Bereich und liegt ueber den anderen, sonst stuende er
wieder ueberall. Damit meinen Liste, Uebersicht, Sortierung
(lib/employee-sort.ts) und Berichte (lib/reports-data.ts) denselben
Bereich -- vorher sortierte die Liste bereits richtig, zeigte aber in
der Spalte etwas anderes an.
2026-09-22 15:10:31 +02:00
3e49be5fc1 Den Bereich fuer die Sortierung rekursiv suchen, nicht in zwei Spruengen
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m47s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Der Ausdruck stieg mit zwei `left join` nach oben, weil der Baum als
hoechstens vierstufig galt. `unit_type` ist aber nur ein Etikett, und die
Organisation kann beliebig tief sein: bei Manner sind es sieben Ebenen.
Fuer 317 der 784 Personen lag der Bereich drei oder vier Spruenge ueber
der eigenen Einheit, der Ausdruck lieferte null, und diese 317 rutschten
beim Sortieren nach Bereich/Team nicht unter ihre Bereiche, sondern
allesamt in einen Block am Ende der Liste.

Der Aufstieg haelt beim ersten Bereich an, nimmt also den naechsten und
nicht den obersten -- sonst stuende bei fast allen "CEO", weil diese
Einheit ueber den sechs C-Level-Bereichen liegt und selbst einer ist.
Dieselbe Regel gilt in lib/reports-data.ts, das von der Wurzel absteigt
und den letzten Treffer nimmt; dort gab es den Fehler nicht.

Gegen den Bestand geprueft: vorher 467 von 784 mit Bereich, jetzt 784.
2026-09-22 14:54:51 +02:00
683e7cc2d7 Keep private email out of the Honestly export
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m6s
The Email column exported employees.email, which is the person's
private address. That does not belong in a file sent to an outside
survey provider, least of all as the address invitations go to in the
employer's name.

The column now stays as a placeholder for the work email, which does
not exist in the schema yet and will be added later. It is empty until
then, but keeps its place so the column mapping set up in Honestly does
not have to change once the address arrives. The export no longer
reads employees.email at all, so it cannot end up in another column
by accident either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:44 +02:00
779d6116b2 Add the Honestly survey export to Berichte
A participant list for the employee survey on honestly.de, offered as
CSV and Excel right under the full data export. Columns: Personalnummer,
Email, Firstname, Last Name, Language, Location, OU, OU+1, ... , Role.
Language is always "de", Role always "Respondee".

The org columns run bottom-up: OU is the person's own unit, OU+1 the one
above, up to the top node. Their number follows the deepest chain in
the file, shorter chains are padded with empty cells, and there is
always at least one OU column so the file keeps its shape.

It exports exactly the people the full export would, with the same
filters. To guarantee that, the selection moved out of the full export's
route into lib/export-auswahl.ts and both routes use it; the full
export's columns are untouched.

Email is employees.email, which is the private address and optional --
there is no work address in the schema. Missing addresses stay empty
rather than getting a placeholder that would receive an invitation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:10:43 +02:00
104 changed files with 9859 additions and 445 deletions

View File

@@ -6,7 +6,7 @@ import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), fn, payload);
@@ -77,6 +77,16 @@ export async function hireEmployee(payload: {
* eindeutig, wenn angegeben.
*/
email?: string;
/**
* Die **dienstliche** Adresse, freiwillig und eindeutig.
*
* Eigenes Feld neben der privaten, weil nur diese das Haus verlassen darf:
* der Export für die Mitarbeiterbefragung geht an einen fremden Anbieter,
* der in unserem Namen Einladungen verschickt.
*/
company_email?: string;
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
cornerstone_id?: string;
phone?: string;
position_id?: string;
team_id?: string;
@@ -87,7 +97,7 @@ export async function hireEmployee(payload: {
contract_end_date?: string;
employment_type?: "Vollzeit" | "Teilzeit";
weekly_hours?: number;
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
paygrade?: PaygradeType;
source: "Intern" | "Extern";
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
@@ -206,7 +216,14 @@ export async function promoteEmployee(payload: {
employee_id: string;
effective_date: string;
new_title: string;
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
new_paygrade?: PaygradeType;
/**
* Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt.
*
* Fehlt sie, bleibt die Person auf ihrer bisherigen — das ist der andere
* der beiden Fälle, nicht ein vergessenes Feld.
*/
target_position_id?: string;
}): Promise<ActionResult> {
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}
@@ -265,6 +282,8 @@ export async function changeEmployeeData(payload: {
*/
export async function rehireEmployee(payload: {
employee_id: string;
company_email?: string;
cornerstone_id?: string;
rehire_date: string;
position_id: string;
/**
@@ -329,6 +348,25 @@ export async function addEmployeeDependent(payload: {
return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
/**
* Eine Angabe berichtigen — ohne Stichtag.
*
* Hinzufügen und Entfernen tragen einen: dort passiert etwas zu einem
* Zeitpunkt. Eine Berichtigung nicht — der Wert war schon vorher falsch, und
* ein „wirksam ab" hiesse, die Person habe bis dahin anders geheissen.
*/
export async function updateEmployeeDependent(payload: {
dependent_id: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string;
birth_date: string;
}): Promise<ActionResult> {
return callRpc("update_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
export async function deleteEmployeeDependent(payload: {
dependent_id: string;
employee_id: string;

47
actions/org.ts Normal file
View File

@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
/**
* Eine Organisationseinheit unter einer bestehenden anlegen.
*
* Bewusst die einzige Änderung an der Struktur, die es aus der Anwendung
* heraus gibt. Umbenennen, verschieben und schliessen fehlen nicht aus
* Zeitmangel: org_units.parent_id trägt kein Datum, ein Verschieben änderte
* damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration
* 20260929120000.
*/
const ORG_PFADE = ["/orgchart", "/positions", "/"];
export async function createOrgUnit(payload: {
parent_id: string;
org_number: string;
name: string;
unit_type: "Bereich" | "Abteilung" | "Team";
valid_from: string;
/** Leer lassen heisst: die Einheit entsteht ohne Leitung. */
leitung_taetigkeit?: string;
}): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), "create_org_unit", payload);
if (!result.success) return result;
// Die Einheit taucht in drei Ansichten auf, und in allen dreien wäre sie
// sonst erst nach dem nächsten harten Neuladen zu sehen.
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}
/**
* Eine Organisationseinheit entfernen.
*
* Geht nur, solange nichts darunter hängt und nie etwas hing. Alles andere
* weist die Datenbankfunktion mit dem Grund ab — ein Schliessen zum Stichtag
* gibt es nicht, weil org_units nirgends gegen den Stichtag gelesen wird.
*/
export async function deleteOrgUnit(orgUnitId: string): Promise<ActionResult> {
const result = await runMutation(await currentUserId(), "delete_org_unit", { org_unit_id: orgUnitId });
if (!result.success) return result;
for (const path of ORG_PFADE) revalidatePath(path);
return result;
}

View File

@@ -2,7 +2,12 @@
import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session";
import { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
import { withUser } from "@/lib/db";
import { jsonArrayFrom } from "@/lib/db/json";
import { runMutation, type ActionResult } from "@/lib/db/rpc";
import { fmtName, todayIso } from "@/lib/format";
import type { PlanstelleZumAendern } from "@/lib/positions";
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
@@ -82,3 +87,88 @@ export async function setPositionCostCenter(payload: {
}): Promise<ActionResult> {
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
}
export type PlanstelleDerEinheit = PlanstelleZumAendern & {
/** Wer am Stichtag darauf sitzt; null heisst unbesetzt. */
besetztVon: string | null;
};
/**
* Die Planstellen einer Organisationseinheit — **auch die besetzten**.
*
* Gelesen, nicht geschrieben, und erst beim Öffnen der Einheit: der Bestand
* zählt 788 Planstellen, und sie alle in jede Zeichnung des Organigramms zu
* legen hiesse, für achtzig Einheiten zu laden, was man für eine braucht.
*
* Der Grund, warum es diese Abfrage überhaupt gibt: die Seite Positionen zeigt
* nur unbesetzte Stellen, und der Änderungsdialog war allein von dort aus
* erreichbar. Die Kontierung einer besetzten Planstelle liess sich damit
* nirgends ändern.
*
* Kein Schutzgatter im Anwendungscode: die Zeilenschutz-Regeln geben einer
* Person ohne HR-Recht nichts zurück, und das ist die Grenze, die zählt.
*/
export async function ladePlanstellenDerEinheit(orgUnitId: string): Promise<{
planstellen: PlanstelleDerEinheit[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server: der Dialog rechnet sonst mit der Zone des Browsers. */
heute: string;
}> {
const heute = todayIso();
const g = await withUser(await currentUserId(), (tx) =>
tx
.selectNoFrom((eb) => [
jsonArrayFrom(
eb
.selectFrom("om_positions as p")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("p.position_number")
).as("stellen"),
jsonArrayFrom(
eb
.selectFrom("position_assignments as a")
.innerJoin("om_positions as p", "p.id", "a.position_id")
.innerJoin("employees as e", "e.id", "a.employee_id")
.select(["a.position_id", "e.first_name", "e.last_name"])
.where("p.org_unit_id", "=", orgUnitId)
.where((x) => x.or([x("a.valid_to", "is", null), x("a.valid_to", ">", heute)]))
).as("besetzungen"),
// Nicht kontierungenAbfrage(): die filtert über eine Liste von
// Planstellen, die hier erst aus der ersten Teilabfrage käme — und das
// wäre eine zweite Rundreise für dieselbe Auskunft.
jsonArrayFrom(
eb
.selectFrom("position_cost_centers as z")
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
.innerJoin("om_positions as p", "p.id", "z.position_id")
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
.where("p.org_unit_id", "=", orgUnitId)
.orderBy("z.valid_from")
).as("kontierungen"),
jsonArrayFrom(kostenstellenAbfrage(eb, heute)).as("kostenstellen"),
])
.executeTakeFirstOrThrow()
);
const kontierung = kontierungZum(g.kontierungen as KontierungsZeile[], heute);
const inhaber = new Map(g.besetzungen.map((b) => [b.position_id, fmtName(b.first_name, b.last_name)]));
return {
planstellen: g.stellen.map((p) => ({
id: p.id,
position_number: p.position_number,
title: p.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
valid_to: p.valid_to,
kostenstelle: kontierung.get(p.id) ?? null,
besetztVon: inhaber.get(p.id) ?? null,
})),
kostenstellen: g.kostenstellen,
heute,
};
}

View File

@@ -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,
@@ -20,7 +20,7 @@ import {
} from "@/lib/employee-sort";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, fmtName, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { breadcrumbLabel, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import type { EmploymentStatus } from "@/lib/types";
@@ -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.
@@ -335,7 +346,6 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<tbody>
{employees.map((e) => {
const placement = placements.get(e.id);
const division = divisionOf(orgMaps, placement?.orgUnitId);
const unit = unitOf(orgMaps, placement?.orgUnitId);
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
return (
@@ -359,14 +369,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{/* tabular-nums keeps the numeric columns aligned down the
page instead of jittering per row. */}
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
<td className="px-4 py-2.5 text-ink-body">
<div>{division?.name ?? "–"}</div>
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
eine Bereichsleitung sitzt am Bereich, nicht an einem
Team, und stand vorher deshalb ohne Zuordnung da. */}
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
{unit && unit.id !== division?.id ? unit.name : "–"}
</div>
{/* Nur die eigene Einheit, egal auf welcher Ebene sie hängt.
Darüber stand der Bereich; der Kunde wollte ihn weg, weil
seine Bereiche CEO, CFO, COO heissen und über jedem Namen
dasselbe wiederholten. Der ganze Weg von oben steht
weiterhin im `title` — eine Einheit wie „Shopleitung"
sagt allein nicht, welche gemeint ist. */}
<td
className="px-4 py-2.5 text-ink-body"
title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
>
{unit?.name ?? "–"}
</td>
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>

View File

@@ -22,7 +22,17 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
const { org, units } = await withUser(await currentUserId(), async (tx) => {
const [org, units] = await Promise.all([
loadOrgAsOf(tx, asOf),
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
tx
.selectFrom("org_units")
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
// der Druck braucht sie nicht.
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
// Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020
// Einheiten, die es damals nicht gab (D.06).
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("org_number")
.execute(),
]);
return { org, units };
});

View File

@@ -70,7 +70,10 @@ export default async function DashboardPage({
const today = todayIso();
const year = today.slice(0, 4);
const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
// Das obere Ende des YTD-Fensters ist **heute**. Hier stand der 31.12., also
// das ganze Kalenderjahr — womit die Kacheln auch zählten, was erst
// bevorsteht (B.02).
const ytdBis = today;
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
@@ -103,7 +106,7 @@ export default async function DashboardPage({
upcomingReturns,
upcomingNotes,
history,
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, ytdBis, bisIso, arten }));
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
// active, and is counted by its own tile instead. FTE follows the same
// set: Karenz contributes no capacity, so including it would overstate
@@ -195,8 +198,13 @@ export default async function DashboardPage({
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
// addieren musste man selbst.
// Die Namen der beiden stehen so fest: Max am 30.09. — „Es gibt zwei
// Headcounts: Headcount Aktiv (nur Aktive) und Headcount Aktives
// Dienstverhältnis (Aktiv + Langzeitabwesenheit)". Vorher hiess nur die
// rechte Kachel „(HC)", und im Bericht stand daneben „Headcount" für
// eine dritte Zahl — dasselbe Wort für Verschiedenes (N.02).
{
label: "Aktives Dienstverhältnis",
label: "Headcount Aktives Dienstverhältnis",
value: activeCount + karenzCount,
tone: "default",
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
@@ -204,10 +212,8 @@ export default async function DashboardPage({
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
href: "/employees?status=Aktiv,Karenz",
},
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
{ label: "Headcount Aktiv", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
{ label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
@@ -224,16 +230,19 @@ export default async function DashboardPage({
titel: "Personalbewegung",
kacheln: [
{
label: "Eintritte (Jahr)",
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
// Der Verweis trägt denselben Zeitraum — sonst zeigte der Bericht
// eine andere Zahl als die Kachel, über die man ihn geöffnet hat.
label: "Eintritte (YTD)",
value: hiresYtd,
tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${ytdBis}`,
},
{
label: "Austritte (Jahr)",
label: "Austritte (YTD)",
value: exitsYtd,
tone: "danger",
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${ytdBis}`,
},
],
},
@@ -363,7 +372,20 @@ export default async function DashboardPage({
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
{/* Der Name führt in die Akte. Ohne den Link war die Karte
eine Sackgasse: man sah, dass etwas passiert ist, und
musste die Person danach in der Liste suchen. */}
{h.first_name && h.last_name ? (
<Link
href={`/employees/${h.employee_id}`}
className="rounded text-sm font-semibold text-ink hover:text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{fmtName(h.first_name, h.last_name)}
</Link>
) : (
<span className="text-sm font-semibold text-ink">Unbekannt</span>
)}
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>

View File

@@ -157,7 +157,7 @@ function BrandPanel() {
<div className="relative max-w-md">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
Die Organisation, so wie sie heute wirklich aussieht.
Alles im Blick. Alles Manner.
</p>
<p className="mt-4 text-sm leading-relaxed text-brand-700">
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen

View File

@@ -0,0 +1,46 @@
import { NextResponse, type NextRequest } from "next/server";
import { baueCornerstoneZeile, CORNERSTONE_CSV, cornerstoneSpalten, type CornerstoneKontext } from "@/lib/cornerstone";
import { loadKontierungen } from "@/lib/cost-centers";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
import { todayIso } from "@/lib/format";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// Der Export für Cornerstone — Spalten und Zuordnung stehen in
// lib/cornerstone.ts.
//
// Dieselben Personen wie im vollständigen Export daneben, mit denselben
// Filtern (lib/export-auswahl.ts). Wer nur die Aktiven einspielen will,
// stellt das auf der Berichtsseite ein, bevor er exportiert.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const stichtag = asOf ?? todayIso();
const { zeilen } = await withUser(gate.userId, async (tx) => {
const { rows, managerKennung } = await ladeExportMitarbeiter(tx, { asOf, filters });
// Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
// Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
// weil erst jetzt feststeht, um welche Planstellen es geht.
const positionIds = [...new Set(rows.map((r) => r.position_id).filter((id): id is string => Boolean(id)))];
const kontext: CornerstoneKontext = {
managerKennung,
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
};
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
});
const filename = exportFilename("cornerstone-export", format);
// Form und Spalten kommen beide aus lib/cornerstone.ts — siehe dort.
const body =
format === "xlsx"
? await toXlsx(zeilen, cornerstoneSpalten(), "Worksheet")
: toCsv(zeilen, cornerstoneSpalten(), CORNERSTONE_CSV);
// Derselbe Umweg über Blob wie in den anderen Exporten (TS#59417).
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}

View File

@@ -1,111 +1,27 @@
import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { fmtName, todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { exportParameter, ladeExportMitarbeiter, type ExportMitarbeiter } from "@/lib/export-auswahl";
import { sortiereWochentage } from "@/lib/wochentage";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { deriveStatusAsOf, type OrgLookups } from "@/lib/reports";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
import type { Database } from "@/lib/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
// über die Planstelle und die abgeleitete Berichtslinie.
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
};
type EmployeeRow = ExportMitarbeiter;
// Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/
// employment filters as the Berichte page. With `asOf` (Stichtag), status
// filtering happens against the *derived* status as of that date rather
// than the live `status` column — see deriveStatusAsOf.
// employment filters as the Berichte page. Wer darin steht, entscheidet
// lib/export-auswahl.ts — dieselbe Auswahl wie im Honestly-Export.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const asOf = parseIsoDateParam(params.get("asOf"));
const filters: ReportFilters = {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
};
const statuses = parseStatuses(filters.status);
const stichtag = asOf ?? todayIso();
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
gate.userId,
async (tx) => {
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
// „Ausgetreten" liess damit genau die Leute aus, die gerade
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
// und einmal im Speicher.
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
}
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
]);
return {
employees,
lookups: lookupResult.lookups,
orgMaps: lookupResult.orgMaps,
allEmployees,
dependentsCounts,
placements,
lines,
};
}
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const { rows, lookups, managerName, dependentsCounts } = await withUser(gate.userId, (tx) =>
ladeExportMitarbeiter(tx, { asOf, filters })
);
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const criteria = filters.criteria ?? LEERE_CRITERIA;
const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
return [{
...e,
org_unit_id: orgUnitId,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
}];
});
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format);
@@ -136,6 +52,8 @@ function employeeExportColumns(
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Firmen-E-Mail", get: (e) => e.company_email },
{ header: "Cornerstone-ID", get: (e) => e.cornerstone_id },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
@@ -179,7 +97,7 @@ function employeeExportColumns(
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade },
{ header: "Hay-Grade", get: (e) => e.paygrade },
{ header: "Herkunft", get: (e) => e.source },
// The export shows the display name, not the raw enum value — a payroll
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit

View File

@@ -0,0 +1,29 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
import { baueHonestlyZeilen, honestlySpalten } from "@/lib/honestly";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// Der Export für die Mitarbeiterbefragung bei Honestly — Spalten und feste
// Werte stehen in lib/honestly.ts.
//
// Dieselben Personen wie im vollständigen Export daneben, mit denselben
// Filtern (lib/export-auswahl.ts). Wer eine Befragung nur für einen Bereich
// oder nur für Aktive will, stellt das auf der Berichtsseite ein, bevor er
// exportiert.
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const { rows, lookups, orgMaps } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
const { zeilen, tiefe } = baueHonestlyZeilen(rows, orgMaps, lookups.locationName);
const spalten = honestlySpalten(tiefe);
const filename = exportFilename("honestly-export", format);
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "Honestly") : toCsv(zeilen, spalten);
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}

View File

@@ -0,0 +1,45 @@
import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo";
import { requireHrUser } from "@/lib/auth/require-hr";
import { withUser } from "@/lib/db";
// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in
// lib/lolyo.ts.
//
// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern
// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen
// will, stellt das auf der Berichtsseite ein, bevor er exportiert.
/**
* Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden.
*
* Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig
* und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer
* lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil
* heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung
* steht in lib/passwort.ts.
*
* Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch
* dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange,
* bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den
* man weiterleitet.
*/
const STARTPASSWORT = "Initialpasswort2026!";
export async function GET(request: NextRequest) {
const gate = await requireHrUser();
if ("denied" in gate) return gate.denied;
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT));
const spalten = lolyoSpalten();
const filename = exportFilename("lolyo-export", format);
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV);
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
}

View File

@@ -22,7 +22,11 @@ const ACTIONS: { value: string; label: string }[] = [
{ value: "Karenz", label: "Langzeitabwesenheit" },
{ value: "Vertragsänderung", label: "Vertragsänderung" },
{ value: "Stammdatenänderung", label: "Stammdatenänderung" },
{ value: "Gehaltsanpassung", label: "Gehaltsanpassung" },
// „Gehaltsanpassung" steht nicht mehr zur Auswahl: das Gehalt ist aus dem
// Funktionsumfang genommen (es wird in Loga geführt), keine Funktion
// schreibt diese Art mehr, und ein Filter, der immer leer ausgeht, sieht
// aus wie ein Fehler. Der Wert bleibt im Aufzählungstyp — alte Einträge
// aus der Zeit davor sollen lesbar bleiben.
];
export function AuditFilters() {

View File

@@ -2,34 +2,48 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { addEmployeeDependent } from "@/actions/employees";
import { addEmployeeDependent, updateEmployeeDependent } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { RelationshipType } from "@/lib/types";
import type { Database, RelationshipType } from "@/lib/types";
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
// Dasselbe Fenster für Anlegen und Berichtigen.
//
// Der Unterschied ist nicht die Maske, sondern der Stichtag: beim Anlegen
// passiert etwas zu einem Zeitpunkt, beim Berichtigen war die Angabe schon
// vorher falsch. Deshalb steht „Wirksam ab" nur im ersten Fall — ein Datum
// bei einer Korrektur hiesse, die Person habe bis dahin anders geheissen.
export function AddDependentModal({
open,
onClose,
employeeId,
defaultEffectiveDate,
dependent,
}: {
open: boolean;
onClose: () => void;
employeeId: string;
defaultEffectiveDate?: string;
/** Gesetzt: berichtigen statt anlegen. */
dependent?: Dependent;
}) {
const { showToast } = useToast();
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [svNummer, setSvNummer] = useState("");
const [birthDate, setBirthDate] = useState("");
const [relationship, setRelationship] = useState<RelationshipType>("Kind");
const bearbeiten = Boolean(dependent);
const [firstName, setFirstName] = useState(dependent?.first_name ?? "");
const [lastName, setLastName] = useState(dependent?.last_name ?? "");
const [svNummer, setSvNummer] = useState(dependent?.sv_nummer ?? "");
const [birthDate, setBirthDate] = useState(dependent?.birth_date ?? "");
const [relationship, setRelationship] = useState<RelationshipType>(
(dependent?.relationship as RelationshipType) ?? "Kind"
);
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
const [pending, setPending] = useState(false);
@@ -53,12 +67,22 @@ export function AddDependentModal({
}
async function handleSubmit() {
if (!firstName || !lastName || !birthDate || !effectiveDate) {
if (!firstName || !lastName || !birthDate || (!bearbeiten && !effectiveDate)) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
setPending(true);
const result = await addEmployeeDependent({
const result = bearbeiten
? await updateEmployeeDependent({
dependent_id: dependent!.id,
employee_id: employeeId,
first_name: firstName,
last_name: lastName,
relationship,
sv_nummer: svNummer,
birth_date: birthDate,
})
: await addEmployeeDependent({
employee_id: employeeId,
first_name: firstName,
last_name: lastName,
@@ -69,10 +93,10 @@ export function AddDependentModal({
});
setPending(false);
if (result.success) {
showToast("Angehörige:r hinzugefügt.");
showToast(bearbeiten ? "Angaben berichtigt." : "Angehörige:r hinzugefügt.");
router.refresh();
onClose();
reset();
if (!bearbeiten) reset();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
@@ -82,20 +106,22 @@ export function AddDependentModal({
<Modal
open={open}
onClose={onClose}
title="Angehörige:n hinzufügen"
title={bearbeiten ? "Angehörige:n berichtigen" : "Angehörige:n hinzufügen"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Hinzufügen
{bearbeiten ? "Speichern" : "Hinzufügen"}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{!bearbeiten && (
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" required value={firstName} onChange={setFirstName} />
<TextField label="Nachname" required value={lastName} onChange={setLastName} />

View File

@@ -1,6 +1,6 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deleteEmployeeDependent } from "@/actions/employees";
@@ -19,6 +19,7 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
const { showToast } = useToast();
const router = useRouter();
const [modalOpen, setModalOpen] = useState(false);
const [bearbeitet, setBearbeitet] = useState<Dependent | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const resolvedEffectiveDate = effectiveDate || todayIso();
@@ -70,6 +71,18 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
<td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? "–"}</td>
<td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td>
<td className="px-4 py-2.5 text-right">
{/* Berichtigen statt löschen-und-neu-anlegen: ein
Tippfehler hinterliess sonst zwei Einträge in der
Akte, und der erste sagte „entfernt", was nicht
stimmte. */}
<Button
variant="icon"
onClick={() => setBearbeitet(d)}
aria-label={`${d.first_name} ${d.last_name} berichtigen`}
className="hover:!text-brand-700"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="icon"
onClick={() => handleDelete(d.id)}
@@ -88,6 +101,19 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
)}
<AddDependentModal open={modalOpen} onClose={() => setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} />
{/* Eigene Einbindung mit `key`: das Fenster bleibt sonst montiert und
zeigte beim zweiten Öffnen die Felder der zuvor bearbeiteten
Person. */}
{bearbeitet && (
<AddDependentModal
key={bearbeitet.id}
open
onClose={() => setBearbeitet(null)}
employeeId={employeeId}
dependent={bearbeitet}
/>
)}
</div>
);
}

View File

@@ -217,15 +217,44 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
{/* Der `key` an jedem Panel ist kein Feinschliff, sondern der Unterschied
zwischen „zeigt Altes" und „schreibt Altes zurück".
Die Panels bleiben eingebunden, damit ihr Ein- und Ausfahren laufen
kann. Sie belegen ihre Felder aber mit useState(employee.…) vor, und
das läuft nur beim ersten Aufbau. Nach einer Beförderung bringt
router.refresh() zwar die frische Akte herein — der Zustand im Panel
bleibt der von vorhin. Beim nächsten Speichern geht er als Ganzes an
change_employee_data, und der eben gesetzte Hay-Grade steht wieder
auf dem alten Wert. Gemeldet im Test vom 29.09. (H.05).
employees.updated_at wechselt bei jeder Änderung an der Zeile
(Trigger trg_employees_touch_updated_at), also genau dann, wenn die
Vorbelegung neu zu lesen ist — und sonst nie. Der Wiedereintritts-
Assistent darunter macht es seit jeher so. */}
<TransferPanel
key={`transfer-${employee.updated_at}`}
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
<PromotePanel
key={`promote-${employee.updated_at}`}
open={panel === "promote"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<KarenzPanel
key={`karenz-${employee.updated_at}`}
open={panel === "karenz"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
/>
<DatenAendernPanel
key={`daten-${employee.updated_at}`}
open={panel === "daten"}
onClose={() => setPanel(null)}
employee={employee}
@@ -233,6 +262,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
locationCountry={location?.country}
/>
<TerminatePanel
key={`terminate-${employee.updated_at}`}
open={panel === "terminate"}
onClose={() => setPanel(null)}
employee={employee}

View File

@@ -9,6 +9,7 @@ import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import { auswahlFuer } from "@/lib/historie-felder";
import type { AuditChange } from "@/lib/types";
// Berichtigen, nicht neu erfassen.
@@ -181,6 +182,28 @@ export function HistorieBearbeiten({
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
</td>
<td className="py-2">
{/* Führt das Feld eine feste Liste, wird sie auch hier
angeboten: getippt entstünde sonst ein Wert, den keine
Auswertung mehr findet — die Berichtigung schreibt in
dieselbe Spalte wie das Formular, nur ohne dessen
Prüfung. Welche Felder das sind, steht in
lib/historie-felder.ts. */}
{auswahlFuer(c.feld) ? (
<select
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border bg-white px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<option value="">leer</option>
{auswahlFuer(c.feld)!.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
) : (
<input
type="text"
aria-label={`${c.feld} — neuer Wert`}
@@ -189,6 +212,7 @@ export function HistorieBearbeiten({
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
</td>
</tr>
))}

View File

@@ -0,0 +1,118 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import { EMERGENCY_RELATIONS } from "@/lib/types";
// Der Notfallkontakt, ohne den Umweg über „Daten ändern".
//
// Geschrieben wird über dieselbe Funktion wie dort: change_employee_data mit
// **nur** den drei Feldern im Abschnitt `person`. Das ist keine Abkürzung,
// sondern die Bedingung dafür, dass der Eintrag in der Historie und im
// Protokoll landet wie jede andere Stammdatenänderung. Die Datenbank fasst
// ausschliesslich die Felder an, die im Aufruf stehen — sie prüft auf das
// Vorhandensein des Schlüssels, nicht auf seinen Wert (Migration
// 20260917120000). Adresse, E-Mail und der Rest bleiben deshalb unberührt.
export type Notfallkontakt = {
name: string | null;
telefon: string | null;
verhaeltnis: string | null;
};
export function NotfallkontaktModal({
open,
onClose,
employeeId,
kontakt,
effectiveDate,
}: {
open: boolean;
onClose: () => void;
employeeId: string;
kontakt: Notfallkontakt;
/** Aus „Daten ändern"; ohne Angabe gilt heute. */
effectiveDate?: string;
}) {
const { showToast } = useToast();
const router = useRouter();
const [name, setName] = useState(kontakt.name ?? "");
const [telefon, setTelefon] = useState(kontakt.telefon ?? "");
const [verhaeltnis, setVerhaeltnis] = useState(kontakt.verhaeltnis ?? "");
const [pending, setPending] = useState(false);
const vorhanden = Boolean(kontakt.name);
async function handleSubmit() {
// Name und Telefonnummer gehören zusammen: ein Name ohne Nummer hilft im
// Ernstfall niemandem, eine Nummer ohne Namen sagt nicht, wen man dran
// hat.
if (!name.trim() || !telefon.trim()) {
showToast("Name und Telefonnummer sind nötig.", "error");
return;
}
setPending(true);
const result = await changeEmployeeData({
employee_id: employeeId,
effective_date: effectiveDate || todayIso(),
person: {
emergency_contact_name: name.trim(),
emergency_contact_phone: telefon.trim(),
emergency_contact_relation: verhaeltnis.trim(),
},
contract: {},
role: {},
});
setPending(false);
if (result.success) {
showToast(vorhanden ? "Notfallkontakt geändert." : "Notfallkontakt hinterlegt.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<Modal
open={open}
onClose={onClose}
title={vorhanden ? "Notfallkontakt ändern" : "Notfallkontakt hinterlegen"}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Speichern
</Button>
</>
}
>
{/* Kein „Wirksam ab": ein Notfallkontakt gilt ab sofort. Das Feld stand
hier, weil die Datenbankfunktion einen Stichtag kennt — bei einer
Telefonnummer, die im Ernstfall gewählt wird, ist ein Datum in der
Zukunft keine sinnvolle Angabe, sondern eine Frage zu viel. */}
<div className="flex flex-col gap-4">
<TextField label="Name" required value={name} onChange={setName} />
<TextField label="Telefonnummer" required type="tel" value={telefon} onChange={setTelefon} />
{/* Dieselbe Liste wie im Einstellungsassistenten (EMERGENCY_RELATIONS)
und kein Freitext: sonst stünden „Gattin", „Ehefrau" und „Frau"
nebeneinander und liessen sich nicht auswerten. */}
<SelectField
label="Verhältnis"
value={verhaeltnis}
onChange={setVerhaeltnis}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import { NotfallkontaktModal, type Notfallkontakt } from "./NotfallkontaktModal";
// Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
// Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt ist die
// Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen. Im Ernstfall
// greift jemand in Eile danach; dann muss die Nummer sofort zu finden sein
// und wählbar.
//
// Der Knopf steht hier, weil er bei den Angehörigen darüber auch steht. Ohne
// ihn führte der einzige Weg über „Daten ändern" — ein Formular über alle
// Stammdaten, um eine Telefonnummer einzutragen.
export function NotfallkontaktSection({
employeeId,
kontakt,
effectiveDate,
}: {
employeeId: string;
kontakt: Notfallkontakt;
/**
* Nur, wenn der Abschnitt in „Daten ändern" steht: dann treibt dessen
* „Wirksam ab" auch diesen Vorgang — wie bei den Angehörigen darüber.
* Ohne Angabe gilt heute.
*/
effectiveDate?: string;
}) {
const { showToast } = useToast();
const router = useRouter();
const [modalOpen, setModalOpen] = useState(false);
const [entfernt, setEntfernt] = useState(false);
const stichtag = effectiveDate || todayIso();
const vorhanden = Boolean(kontakt.name);
async function handleEntfernen() {
setEntfernt(true);
// Leere Zeichenketten, nicht ausgelassene Felder: die Datenbank macht
// daraus null. Würden die Schlüssel fehlen, bliebe der Kontakt stehen.
const result = await changeEmployeeData({
employee_id: employeeId,
effective_date: stichtag,
person: { emergency_contact_name: "", emergency_contact_phone: "", emergency_contact_relation: "" },
contract: {},
role: {},
});
setEntfernt(false);
if (result.success) {
showToast("Notfallkontakt entfernt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
}
return (
<div className="border-t border-border pt-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
{/* Nur, solange keiner hinterlegt ist: es gibt genau einen. Ein
zweites „Hinzufügen" daneben verspräche eine Liste, die es hier
nicht gibt — geändert und entfernt wird in der Zeile selbst. */}
{!vorhanden && (
<Button
variant="ghost"
size="sm"
onClick={() => setModalOpen(true)}
className="!px-1 text-brand-700 hover:!bg-transparent hover:underline"
>
<Plus className="h-3.5 w-3.5" /> Hinzufügen
</Button>
)}
</div>
{/* Dieselbe Tabelle wie bei den Angehörigen darüber, Zeile für Zeile
derselbe Aufbau. Vorher stand hier eine Beschreibungsliste — fachlich
dasselbe, optisch ein zweiter Baustil im selben Reiter. Zwei Listen
von Personen im Umfeld sollen gleich aussehen. */}
{vorhanden ? (
<div className="overflow-x-auto rounded border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-accent-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Telefon</th>
<th className="px-4 py-2.5">Verhältnis</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2.5 font-semibold text-ink">{kontakt.name}</td>
<td className="px-4 py-2.5 text-ink-body">
{kontakt.telefon ? (
// Wählbar: im Ernstfall greift jemand in Eile danach.
<a
href={`tel:${kontakt.telefon.replace(/\s/g, "")}`}
className="rounded font-semibold hover:text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{kontakt.telefon}
</a>
) : (
"–"
)}
</td>
<td className="px-4 py-2.5 text-ink-body">{kontakt.verhaeltnis || "–"}</td>
<td className="px-4 py-2.5 text-right">
<Button
variant="icon"
onClick={() => setModalOpen(true)}
aria-label="Notfallkontakt ändern"
className="hover:!text-brand-700"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="icon"
onClick={handleEntfernen}
pending={entfernt}
aria-label="Notfallkontakt entfernen"
className="hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
) : (
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
)}
{/* `key` setzt die Felder auf den gespeicherten Stand zurück, sobald
sich der Kontakt geändert hat — das Fenster bleibt sonst montiert
und zeigte beim zweiten Öffnen den alten Entwurf. */}
<NotfallkontaktModal
key={`${kontakt.name ?? ""}|${kontakt.telefon ?? ""}|${kontakt.verhaeltnis ?? ""}`}
open={modalOpen}
onClose={() => setModalOpen(false)}
employeeId={employeeId}
kontakt={kontakt}
effectiveDate={stichtag}
/>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { SelectField, TextField } from "@/components/ui/Field";
import {
BEGUENSTIGUNG_AB_GRAD,
GRUND_BEGUENSTIGT_BEHINDERT,
KUENDIGUNGSSCHUTZ_GRUENDE,
} from "@/lib/kuendigungsschutz";
@@ -222,7 +221,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
{value.hasKuendigungsschutz && (
<>
<SelectField
label="Personenkreis"
label="Grund"
dense
value={value.kuendigungsschutzGrund}
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
@@ -278,7 +277,6 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
type="number"
value={value.behinderungGrad}
onChange={(v) => onChange({ behinderungGrad: v })}
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
/>
<TextField
label="Bescheid ab"

View File

@@ -13,12 +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 { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types";
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
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"];
@@ -64,10 +66,9 @@ export function DatenAendernPanel({
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email ?? "");
const [companyEmail, setCompanyEmail] = useState(employee.company_email ?? "");
const [cornerstoneId, setCornerstoneId] = useState(employee.cornerstone_id ?? "");
const [phone, setPhone] = useState(employee.phone ?? "");
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
@@ -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",
@@ -135,12 +137,6 @@ export function DatenAendernPanel({
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return;
}
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
// andere ab, und die Meldung dort erklärt es nicht.
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
return;
}
setPending(true);
const result = await changeEmployeeData({
employee_id: employee.id,
@@ -164,17 +160,25 @@ export function DatenAendernPanel({
postal_code: postalCode,
city,
address_country: addressCountry,
email,
phone,
emergency_contact_name: notfallName.trim(),
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
// Getrimmt und als leere Zeichenkette durchgereicht: die Datenbank
// macht daraus null (nullif), damit sich eine Angabe auch wieder
// entfernen lässt. Ungetrimmt käme ein Feld, in dem nur ein
// Leerzeichen stehen geblieben ist, als Inhalt in der Spalte an.
email: email.trim(),
company_email: companyEmail.trim(),
cornerstone_id: cornerstoneId.trim(),
phone: phone.trim(),
// Der Notfallkontakt läuft über seinen eigenen Abschnitt weiter unten
// — wie die Angehörigen. Stünde er zusätzlich hier, schrieben zwei
// Stellen dasselbe Feld, und welche gewinnt, hinge an der
// Reihenfolge.
},
contract: {
employment_type: employmentType,
weekly_hours: Number(weeklyHours),
contract_type: contractType,
contract_end_date: contractType === "befristet" ? contractEndDate : "",
paygrade,
},
role: {
worker_type: role.workerType,
@@ -308,27 +312,12 @@ export function DatenAendernPanel({
<Field label="Land" dense>
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
</Field>
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="E-Mail (privat)" dense type="email" value={email} onChange={setEmail} />
<TextField label="Firmen-E-Mail" dense type="email" value={companyEmail} onChange={setCompanyEmail} />
<TextField label="Cornerstone-ID" dense value={cornerstoneId} onChange={setCornerstoneId} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div>
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
er nicht zwischen den Adressfeldern der Person untergehen. */}
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
<SelectField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>
</div>
<div>
@@ -386,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>
@@ -395,6 +396,22 @@ export function DatenAendernPanel({
</div>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
{/* Derselbe Abschnitt wie im Reiter „Stammdaten", unmittelbar unter den
Angehörigen — beides Personen im Umfeld, beides dieselbe Tabelle
mit denselben Knöpfen. Hier standen drei Eingabefelder: fachlich
dasselbe, aber daneben eine zweite Bauform für denselben Zweck.
Beide Abschnitte laufen jetzt über das „Wirksam ab" dieses
Formulars. */}
<NotfallkontaktSection
employeeId={employee.id}
effectiveDate={effectiveDate}
kontakt={{
name: employee.emergency_contact_name,
telefon: employee.emergency_contact_phone,
verhaeltnis: employee.emergency_contact_relation,
}}
/>
</div>
</SlideOver>
);

View File

@@ -1,45 +1,86 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import { promoteEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { HAY_GRADES } from "@/lib/hay-grade";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database, PaygradeType } from "@/lib/types";
import { fmtName } from "@/lib/format";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ value: "A", label: "A – Einstieg" },
{ value: "B", label: "B – Qualifiziert" },
{ value: "C", label: "C – Erfahren" },
{ value: "D", label: "D – Spezialist:in" },
{ value: "E", label: "E – Teamleitung" },
{ value: "F", label: "F – Bereichsleitung / GF" },
];
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
// bei der Versetzung.
type Stellenwahl = "selbe" | "neue";
export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
export function PromotePanel({
open,
onClose,
employee,
openPositions,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
openPositions: OpenPositionResolved[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [newTitle, setNewTitle] = useState(employee.job_title);
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
const [positionId, setPositionId] = useState("");
const [pending, setPending] = useState(false);
const options = useMemo(
() =>
openPositions
.slice()
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
/**
* Die Stelle wählen schlägt ihre Tätigkeit als neue Bezeichnung vor.
*
* Nur ein Vorschlag: `employees.job_title` darf von der Planstelle
* abweichen, und wer eine eigene Bezeichnung tippt, soll sie behalten.
* Deshalb wird nur überschrieben, solange das Feld unverändert auf dem
* alten Titel steht.
*/
function stelleWaehlen(id: string) {
setPositionId(id);
const stelle = openPositions.find((p) => p.id === id);
if (stelle && newTitle === employee.job_title) setNewTitle(stelle.title);
}
async function handleSubmit() {
if (!effectiveDate || !newTitle) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
if (stellenwahl === "neue" && !positionId) {
showToast("Bitte die neue Planstelle auswählen.", "error");
return;
}
setPending(true);
const result = await promoteEmployee({
employee_id: employee.id,
effective_date: effectiveDate,
new_title: newTitle,
new_paygrade: paygrade,
target_position_id: stellenwahl === "neue" ? positionId : undefined,
});
setPending(false);
if (result.success) {
@@ -72,12 +113,62 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
>
<div className="flex flex-col gap-4">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<fieldset>
<legend className="mb-1.5 text-sm font-semibold text-ink-body">Planstelle</legend>
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="radio"
name="stellenwahl"
checked={stellenwahl === "selbe"}
onChange={() => setStellenwahl("selbe")}
/>
Selbe Planstelle
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input
type="radio"
name="stellenwahl"
checked={stellenwahl === "neue"}
onChange={() => setStellenwahl("neue")}
disabled={options.length === 0}
/>
Neue Planstelle
{options.length === 0 && <span className="text-xs text-ink-muted">(derzeit keine freie)</span>}
</label>
</div>
</fieldset>
{stellenwahl === "neue" && (
<>
<SelectField
label="Neue Planstelle"
required
value={positionId}
onChange={stelleWaehlen}
placeholder="Bitte wählen…"
options={options}
/>
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
</div>
</div>
)}
</>
)}
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<SelectField
label="Paygrade"
label="Hay-Grade"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}
options={HAY_GRADES}
/>
</div>
</SlideOver>

View File

@@ -36,7 +36,7 @@ type Sicht = "alle" | "anstehend" | "erledigt";
const SICHTEN: { value: Sicht; label: string }[] = [
{ value: "alle", label: "Alle" },
{ value: "anstehend", label: "Anstehend" },
{ value: "erledigt", label: "Gelaufen" },
{ value: "erledigt", label: "Vergangen" },
];
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {

View File

@@ -1,4 +1,5 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
import { brauchtAufenthaltstitel } from "@/lib/countries";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/types";
@@ -52,6 +53,8 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
["Ort", employee.city ?? "–"],
["Land", employee.address_country ?? "–"],
["Private E-Mail", employee.email ?? "–"],
["Firmen-E-Mail", employee.company_email ?? "–"],
["Cornerstone-ID", employee.cornerstone_id ?? "–"],
["Private Telefonnummer", employee.phone ?? "–"],
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
@@ -71,43 +74,14 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
sofort zu finden sein und wählbar. */}
<div className="border-t border-border pt-6">
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
{employee.emergency_contact_name ? (
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Name</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_name}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Telefon</dt>
<dd className="mt-1 text-sm font-semibold text-ink">
{employee.emergency_contact_phone ? (
<a
href={`tel:${employee.emergency_contact_phone.replace(/\s/g, "")}`}
className="rounded hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{employee.emergency_contact_phone}
</a>
) : (
"–"
)}
</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Verhältnis</dt>
<dd className="mt-1 text-sm text-ink">{employee.emergency_contact_relation || "–"}</dd>
</div>
</dl>
) : (
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
)}
</div>
<NotfallkontaktSection
employeeId={employee.id}
kontakt={{
name: employee.emergency_contact_name,
telefon: employee.emergency_contact_phone,
verhaeltnis: employee.emergency_contact_relation,
}}
/>
</div>
);
}

View File

@@ -1,17 +1,48 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate } from "@/lib/format";
import { hayGradeLabel } from "@/lib/hay-grade";
import type { Database } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
const PAYGRADE_LABELS: Record<string, string> = {
A: "A – Einstieg",
B: "B – Qualifiziert",
C: "C – Erfahren",
D: "D – Spezialist:in",
E: "E – Teamleitung",
F: "F – Bereichsleitung / GF",
};
/**
* Der Kündigungsschutz mit Grund und Zeitraum.
*
* Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn
* und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030"
* sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09).
*
* Die Wortwahl für den einfachsten Fall bleibt: steht nur das Kennzeichen da,
* heisst es weiter „Ja (Ende offen)".
*/
function kuendigungsschutzText(e: EmployeeRow): string {
if (!e.has_kuendigungsschutz) return "Nein";
const teile = [
e.kuendigungsschutz_grund,
e.kuendigungsschutz_ab ? `ab ${fmtDate(e.kuendigungsschutz_ab)}` : null,
e.kuendigungsschutz_bis ? `bis ${fmtDate(e.kuendigungsschutz_bis)}` : null,
].filter((t): t is string => Boolean(t));
if (teile.length === 0) return "Ja (Ende offen)";
return e.kuendigungsschutz_bis ? teile.join(" · ") : `${teile.join(" · ")} · Ende offen`;
}
/**
* Die Begünstigung als eigene Zeile, nicht an den Kündigungsschutz gehängt.
*
* Sie führt zwar oft zu besonderem Kündigungsschutz, ist aber ein eigener
* Bescheid — und der Grad gehört zur Behinderung, nicht zum Schutz.
*/
function behinderungText(e: EmployeeRow): string {
if (!e.ist_beguenstigt_behindert) return "Nein";
const teile = [
e.behinderung_grad !== null ? `${e.behinderung_grad} %` : null,
e.behinderung_ab ? `Bescheid ab ${fmtDate(e.behinderung_ab)}` : null,
e.behinderung_bis ? `bis ${fmtDate(e.behinderung_bis)}` : null,
].filter((t): t is string => Boolean(t));
return teile.length > 0 ? teile.join(" · ") : "Ja";
}
export function VertragTab({ employee }: { employee: EmployeeRow }) {
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
@@ -26,7 +57,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
["Beschäftigungsausmaß", employee.employment_type],
["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Hay-Grade", hayGradeLabel(employee.paygrade)],
["Beschäftigtengruppe", employee.worker_type ?? "–"],
["Arbeitstage", employee.work_days?.join(", ") || "–"],
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
@@ -47,14 +78,17 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
: `${employee.teilzeit_art} (Ende offen)`
: "–",
],
[
"Besonderer Kündigungsschutz",
employee.has_kuendigungsschutz
? employee.kuendigungsschutz_bis
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
: "Ja (Ende offen)"
: "Nein",
],
// Grund und Zeitraum stehen dabei, nicht nur das Enddatum. Gemeldet am
// 29.09.: erfasst werden sieben Felder — Personenkreis, Beginn, Ende,
// Behinderung mit Grad und Bescheidzeitraum —, sichtbar war in der Akte
// nur „bis TT.MM.JJJJ". Wer den Grund brauchte, fand ihn nur in der
// Historie oder über einen Bericht.
["Besonderer Kündigungsschutz", kuendigungsschutzText(employee)],
// Eigene Zeile und nicht an den Kündigungsschutz gehängt: die Begünstigung
// ist ein Bescheid für sich. Sie führt zwar oft zu besonderem
// Kündigungsschutz, ist aber nicht dasselbe, und der Grad gehört zur
// Behinderung, nicht zum Schutz.
["Begünstigt behindert", behinderungText(employee)],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -148,6 +148,8 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim() || undefined,
company_email: draft.companyEmail.trim() || undefined,
cornerstone_id: draft.cornerstoneId.trim() || undefined,
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,

View File

@@ -124,6 +124,8 @@ export function RehireWizard({ open, onClose, employee, openPositions, locations
birth_date: draft.birthDate,
sv_nummer: draft.svNummer.trim(),
email: draft.email.trim(),
company_email: draft.companyEmail.trim(),
cornerstone_id: draft.cornerstoneId.trim(),
phone: draft.phone.trim(),
location_id: draft.locationId,
// Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst

View File

@@ -68,6 +68,18 @@ export function StepPerson({ draft, update, locations, onNummerBefund, nummerGes
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
will, soll nicht gezwungen sein, eine zu erfinden. */}
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField
label="Firmen-E-Mail"
type="email"
value={draft.companyEmail}
onChange={(companyEmail) => update({ companyEmail })}
/>
<TextField
label="Cornerstone-ID"
value={draft.cornerstoneId}
onChange={(cornerstoneId) => update({ cornerstoneId })}
hint="Freiwillig. Füllt im Cornerstone-Export „User ID“ und „Username“."
/>
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
</div>
<SelectField

View File

@@ -1,17 +1,9 @@
import { dienstwagenLabel } from "@/lib/dienstwagen";
import { fmtDate, fmtFullName } from "@/lib/format";
import { hayGradeLabel } from "@/lib/hay-grade";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
const PAYGRADE_LABELS: Record<string, string> = {
A: "A – Einstieg",
B: "B – Qualifiziert",
C: "C – Erfahren",
D: "D – Spezialist:in",
E: "E – Teamleitung",
F: "F – Bereichsleitung / GF",
};
type StepSummaryProps = {
draft: HireDraftData;
selectedPosition: OpenPositionResolved | null;
@@ -49,7 +41,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Eintrittsdatum", fmtDate(draft.entryDate)],
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
["Hay-Grade", hayGradeLabel(draft.paygrade)],
["Beschäftigtengruppe", draft.workerType],
["Kollektivvertrag", draft.collectiveAgreement],
["Arbeitstage", draft.workDays.join(", ") || "–"],

View File

@@ -1,17 +1,9 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import { SelectField, TextField } from "@/components/ui/Field";
import { HAY_GRADES } from "@/lib/hay-grade";
import type { PaygradeType } from "@/lib/types";
import type { HireDraftData } from "./types";
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
{ value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
{ value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
{ value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
{ value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" },
{ value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
{ value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
];
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
update({
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/>
</div>
<SelectField
label="Paygrade"
label="Hay-Grade"
required
value={draft.paygrade}
onChange={(v) => update({ paygrade: v as PaygradeType })}
options={PAYGRADES}
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
options={HAY_GRADES}
/>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>

View File

@@ -1,3 +1,4 @@
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
@@ -34,6 +35,9 @@ export type HireDraftData = {
birthDate: string;
svNummer: string;
email: string;
companyEmail: string;
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
cornerstoneId: string;
phone: string;
locationId: string;
positionId: string;
@@ -82,6 +86,8 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
birthDate: "",
svNummer: "",
email: "",
companyEmail: "",
cornerstoneId: "",
phone: "",
locationId: "",
positionId: "",
@@ -91,7 +97,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
contractEndDate: "",
employmentType: "Vollzeit",
weeklyHours: "38.5",
paygrade: "B",
paygrade: HAY_GRADE_STANDARD,
workerType: "Angestellte:r",
collectiveAgreement: "Handel",
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],

View File

@@ -2,8 +2,9 @@
import { CalendarClock } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
type AsOfPickerProps = {
@@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
}
// Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField.
const [entwurf, setEntwurf] = useState(asOf);
const [zuletzt, setZuletzt] = useState(asOf);
if (asOf !== zuletzt) {
setZuletzt(asOf);
setEntwurf(asOf);
}
const isToday = asOf === today;
const isFuture = asOf > today;
// Assignments only started being recorded when the history table was
@@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
<CalendarClock className="h-4 w-4 text-ink-muted" />
Stichtag
</label>
{/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges
Datum: jede Änderung ist eine Navigation, und beim Tippen der
Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon
lud die Seite neu und setzte das Feld mitten im Tippen zurück.
Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */}
<input
id="orgchart-asof"
type="date"
value={asOf}
onChange={(e) => setAsOf(e.target.value || undefined)}
value={entwurf}
onChange={(e) => {
const wert = e.target.value;
setEntwurf(wert);
if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined);
}}
onBlur={() => {
if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined);
}}
className={FILTER_SELECT_CLASS}
/>
{!isToday && (

View File

@@ -0,0 +1,126 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createOrgUnit } from "@/actions/org";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import { naechsteOrgnummer } from "@/lib/org-nummer";
import type { OrgUnitNode } from "./types";
type Art = "Bereich" | "Abteilung" | "Team";
// „Gesellschaft“ fehlt in der Auswahl mit Absicht: es gibt genau eine, sie ist
// die Wurzel, und unter einer anderen Einheit wäre sie ein Etikett, das seiner
// Stelle im Baum widerspricht. Die Datenbankfunktion weist es ohnehin ab — die
// fehlende Zeile erspart den Weg dorthin.
const ARTEN: { value: Art; label: string }[] = [
{ value: "Bereich", label: "Bereich" },
{ value: "Abteilung", label: "Abteilung" },
{ value: "Team", label: "Team" },
];
/**
* Die Art, die unter einer Einheit am wahrscheinlichsten ist: eine Stufe
* feiner als die darüber. Nur eine Vorbelegung — die Tiefe des Baums ist frei,
* und eine Abteilung unter einer Abteilung ist erlaubt.
*/
function vorschlagArt(eltern: OrgUnitNode["unit_type"]): Art {
if (eltern === "Gesellschaft") return "Bereich";
if (eltern === "Bereich") return "Abteilung";
return "Team";
}
export function CreateOrgUnitModal({
parent,
units,
onClose,
}: {
parent: OrgUnitNode;
units: OrgUnitNode[];
onClose: () => void;
}) {
const { showToast } = useToast();
const router = useRouter();
const [orgNumber, setOrgNumber] = useState(() => naechsteOrgnummer(units.map((u) => u.org_number)) ?? "");
const [name, setName] = useState("");
const [art, setArt] = useState<Art>(() => vorschlagArt(parent.unit_type));
// **Nicht** der Stichtag der Ansicht. Wer sich das Organigramm zum letzten
// Jahresende ansieht und hier auf Plus drückt, will in aller Regel eine
// Einheit von heute anlegen und keine rückdatierte. Ein stillschweigend
// übernommenes Datum aus der Ansicht wäre die Art von Vorbelegung, die
// niemand liest und die hinterher niemand erklären kann.
const [validFrom, setValidFrom] = useState(todayIso);
const [leitung, setLeitung] = useState("");
const [pending, setPending] = useState(false);
async function handleSubmit() {
if (!orgNumber.trim() || !name.trim() || !validFrom) {
showToast("Orgnummer, Bezeichnung und Gültigkeitsbeginn sind Pflicht.", "error");
return;
}
setPending(true);
const result = await createOrgUnit({
parent_id: parent.id,
org_number: orgNumber.trim(),
name: name.trim(),
unit_type: art,
valid_from: validFrom,
leitung_taetigkeit: leitung.trim() || undefined,
});
setPending(false);
if (result.success) {
showToast(leitung.trim() ? "Einheit samt Leitungsplanstelle angelegt." : "Einheit angelegt.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Anlegen.", "error");
}
}
return (
<Modal
open
onClose={onClose}
title="Einheit anlegen"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button onClick={handleSubmit} pending={pending}>
Anlegen
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
Unter <span className="font-semibold text-ink-body">{parent.org_number} · {parent.name}</span>.
Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer.
</p>
<TextField
label="Orgnummer"
required
value={orgNumber}
onChange={setOrgNumber}
hint="Vorgeschlagen wird die nächste freie Nummer der bestehenden Reihe. Sie muss zum führenden System passen."
/>
<TextField label="Bezeichnung" required value={name} onChange={setName} />
<SelectField label="Art" required value={art} onChange={(v) => setArt(v as Art)} options={ARTEN} />
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
<TextField
label="Leitungsplanstelle (optional)"
value={leitung}
onChange={setLeitung}
hint="Tätigkeit der Leitung, z. B. „Abteilungsleitung Einkauf“. Leer lassen, dann entsteht die Einheit ohne Leitung und ist im Organigramm als vakant zu sehen."
/>
</div>
</Modal>
);
}

View File

@@ -39,6 +39,10 @@ export type GraphOrgChartProps = {
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | null;
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
onSelectUnit?: (unitId: string) => void;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
@@ -49,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
function GraphOrgChartInner({
tree,
isExpanded,
onToggle,
matchedIds,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
@@ -65,6 +77,9 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
hasChildren: n.children.length > 0,
childCount: n.children.length,
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
@@ -76,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);

View File

@@ -1,7 +1,7 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import { Building2, ChevronDown, Plus, Trash2, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
@@ -14,6 +14,11 @@ export type OrgChartNodeData = {
hasChildren: boolean;
childCount: number;
onToggle: (id: string) => void;
/** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
/** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */
onSelectUnit?: (unitId: string) => void;
};
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
@@ -42,8 +47,8 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
// React Flow re-renders node components on every pan/zoom frame — memo is
// required, not just tidy, to keep that smooth at a few hundred nodes.
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
const isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind];
@@ -107,6 +112,50 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
>
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
{/* Erscheint erst beim Überfahren der Karte: auf achtzig Einheiten wären
achtzig ständig sichtbare Pluszeichen ein Muster und kein Angebot.
Auf Geräten ohne Zeigegerät bleibt er stehen — dort gibt es kein
Überfahren, und unsichtbar hiesse dann unerreichbar. */}
{unitId && (onAddUnit || onDeleteUnit) && (
<span
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex items-center gap-1
opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100
[@media(hover:none)]:opacity-100"
>
{/* Der Papierkorb nur am Blatt. Eine Einheit mit Kindern lässt sich
ohnehin nicht entfernen — der Knopf wäre dort ein Angebot, das
beim Klick eine Absage erteilt, und das ist schlechter als kein
Knopf. Die Datenbank prüft es trotzdem: sie sieht auch
geschlossene Planstellen, die hier gar nicht gezeichnet sind. */}
{onDeleteUnit && !hasChildren && (
<button
type="button"
onClick={() => onDeleteUnit(unitId)}
aria-label={`Einheit ${label} entfernen`}
title="Einheit entfernen"
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
transition-colors hover:border-danger-solid hover:bg-danger-solid hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{onAddUnit && (
<button
type="button"
onClick={() => onAddUnit(unitId)}
aria-label={`Einheit unter ${label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</span>
)}
{/* Accent stripe, inset so it follows the card's rounded corner. */}
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
@@ -114,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
{content}
</Link>
) : unitId && onSelectUnit ? (
// Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist
// die Fläche das Ziel, das man trifft, ohne hinzusehen.
<button
type="button"
onClick={() => onSelectUnit(unitId)}
aria-label={`Angaben zu ${label}`}
className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3 text-left
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{content}
</button>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
)}

View File

@@ -0,0 +1,241 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions";
import { EditPositionModal } from "@/components/positions/EditPositionModal";
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { Button } from "@/components/ui/Button";
import { SlideOver } from "@/components/ui/SlideOver";
import { fmtDate, fmtName } from "@/lib/format";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
// Was an einer Organisationseinheit steht — beim Klick auf ihre Karte.
//
// Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat:
// Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher
// Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte
// zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.
function Zeile({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 border-b border-border-subtle py-1.5 last:border-0">
<span className="shrink-0 text-xs text-ink-muted">{label}</span>
<span className="text-right text-sm text-ink-body">{children}</span>
</div>
);
}
export function OrgUnitPanel({
unit,
units,
unitOptions,
employees,
vacancies,
onAddUnit,
onDeleteUnit,
onClose,
}: {
unit: OrgUnitNode;
units: OrgUnitNode[];
/** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */
unitOptions: UnitOption[];
employees: OrgEmployee[];
vacancies: OrgVacancy[];
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
onClose: () => void;
}) {
// Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund
// bei ladePlanstellenDerEinheit.
const [stellen, setStellen] = useState<PlanstelleDerEinheit[] | null>(null);
const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]);
const [heute, setHeute] = useState("");
const [bearbeitet, setBearbeitet] = useState<PlanstelleDerEinheit | null>(null);
const laden = useCallback(async () => {
const daten = await ladePlanstellenDerEinheit(unit.id);
setStellen(daten.planstellen);
setKostenstellen(daten.kostenstellen);
setHeute(daten.heute);
}, [unit.id]);
useEffect(() => {
let verworfen = false;
// Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre
// hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde
// Planstellen.
void ladePlanstellenDerEinheit(unit.id).then((daten) => {
if (verworfen) return;
setStellen(daten.planstellen);
setKostenstellen(daten.kostenstellen);
setHeute(daten.heute);
});
return () => {
verworfen = true;
};
}, [unit.id]);
const kinder = units.filter((u) => u.parent_id === unit.id);
// Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist
// heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die
// ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es
// liegt.
const kette: OrgUnitNode[] = [];
const gesehen = new Set<string>();
let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined;
while (lauf && !gesehen.has(lauf.id)) {
gesehen.add(lauf.id);
kette.unshift(lauf);
lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined;
}
const eigene = employees.filter((e) => e.org_unit_id === unit.id);
const leitung = eigene.find((e) => e.is_chief);
const belegschaft = eigene.filter((e) => !e.is_chief);
const offene = vacancies.filter((v) => v.org_unit_id === unit.id);
// Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie
// gross ist dieser Bereich" meint immer den ganzen Teilbaum.
const teilbaum = new Set<string>([unit.id]);
for (let gewachsen = true; gewachsen; ) {
gewachsen = false;
for (const u of units) {
if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) {
teilbaum.add(u.id);
gewachsen = true;
}
}
}
const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length;
// Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne
// Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht
// auch geschlossene Planstellen, die hier nicht gezeichnet sind.
const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null;
return (
<SlideOver open onClose={onClose} title={unit.name} subtitle={`${unit.org_number} · ${unit.unit_type}`}>
<div className="flex flex-col gap-5">
<div>
<Zeile label="Orgnummer">{unit.org_number}</Zeile>
<Zeile label="Art">{unit.unit_type}</Zeile>
<Zeile label="Übergeordnet">
{kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"}
</Zeile>
<Zeile label="Gültig ab">{unit.valid_from ? fmtDate(unit.valid_from) : "—"}</Zeile>
<Zeile label="Gültig bis">{unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Besetzung</h3>
<Zeile label="Leitung">
{leitung ? (
<Link href={`/employees/${leitung.id}`} className="text-brand-700 hover:underline">
{fmtName(leitung.first_name, leitung.last_name)}
</Link>
) : (
<span className="font-semibold text-warning-text">vakant</span>
)}
</Zeile>
<Zeile label="Mitarbeitende">{belegschaft.length}</Zeile>
<Zeile label="Unbesetzte Planstellen">{offene.length}</Zeile>
</div>
<div>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Struktur</h3>
<Zeile label="Untergeordnete Einheiten">{kinder.length}</Zeile>
<Zeile label="Personen im gesamten Teilbaum">{imTeilbaum}</Zeile>
</div>
{kinder.length > 0 && (
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkt darunter</h3>
<ul className="flex flex-col gap-1 text-sm text-ink-body">
{kinder.map((k) => (
<li key={k.id}>
<span className="tabular-nums text-ink-muted">{k.org_number}</span> · {k.name}{" "}
<span className="text-xs text-ink-muted">({k.unit_type})</span>
</li>
))}
</ul>
</div>
)}
<div>
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">
Planstellen{stellen ? ` (${stellen.length})` : ""}
</h3>
{stellen === null ? (
<p className="text-sm text-ink-muted">Lädt…</p>
) : stellen.length === 0 ? (
<p className="text-sm text-ink-muted">In dieser Einheit besteht keine Planstelle.</p>
) : (
<ul className="flex flex-col divide-y divide-border-subtle">
{stellen.map((s) => (
<li key={s.id}>
{/* Auch die besetzten sind anklickbar. Was sich daran ändern
lässt, entscheidet die Datenbankfunktion: Einheit und
Gültigkeitsende bleiben bei einer besetzten Stelle
gesperrt, Tätigkeit und Kontierung nicht. */}
<button
type="button"
onClick={() => setBearbeitet(s)}
className="flex w-full flex-col items-start rounded py-1.5 text-left hover:bg-surface
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<span className="text-sm text-ink-body">
<span className="tabular-nums text-ink-muted">{s.position_number}</span> · {s.title}
{s.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
</span>
<span className="text-xs text-ink-muted">
{s.besetztVon ?? <span className="font-semibold text-warning-text">unbesetzt</span>}
{s.kostenstelle ? ` · Kostenstelle ${s.kostenstelle.code}` : " · ohne Kostenstelle"}
</span>
</button>
</li>
))}
</ul>
)}
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
Alle unbesetzten Planstellen
</Link>
</div>
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
<Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}>
<Plus className="h-4 w-4" />
Einheit darunter anlegen
</Button>
{entfernbar && (
<Button variant="secondary" size="sm" onClick={() => onDeleteUnit(unit.id)}>
<Trash2 className="h-4 w-4" />
Einheit entfernen
</Button>
)}
</div>
</div>
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der
dasselbe tut, liefe beim ersten Zusatz auseinander. */}
{bearbeitet && (
<EditPositionModal
key={bearbeitet.id}
position={bearbeitet}
units={unitOptions}
kostenstellen={kostenstellen}
heute={heute}
onClose={() => {
setBearbeitet(null);
// Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes
// Laden stünde nach dem Umkontieren weiter die alte Kostenstelle
// da, und der Dialog daneben zeigte die neue.
void laden();
}}
/>
)}
</SlideOver>
);
}

View File

@@ -1,10 +1,15 @@
"use client";
import { ChevronDown, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { deleteOrgUnit } from "@/actions/org";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { useToast } from "@/components/ui/Toast";
import { CreateOrgUnitModal } from "./CreateOrgUnitModal";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import { OrgUnitPanel } from "./OrgUnitPanel";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
import { fmtName } from "@/lib/format";
@@ -27,6 +32,8 @@ type PositionTreeProps = {
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
const [mode, setMode] = useState<ViewMode>("list");
const { showToast } = useToast();
const router = useRouter();
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
@@ -41,8 +48,70 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
// Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und
// nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein
// festgehaltenes Objekt wäre dann der Stand von vorhin.
const [anlegenUnter, setAnlegenUnter] = useState<string | null>(null);
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null;
// Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher
// ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort.
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []);
const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? null) : null;
// Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur,
// was nie eine Planstelle getragen hat und nichts unter sich hat. Alles
// andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast.
const onDeleteUnit = useCallback(
async (unitId: string) => {
const betroffen = units.find((u) => u.id === unitId);
const result = await deleteOrgUnit(unitId);
if (result.success) {
showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`);
// Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst
// offen da.
setGewaehlt(null);
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
},
[units, showToast, router]
);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
// Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet.
// Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst
// dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau
// die Leitung im Baum — besetzt oder als Vakanz.
const unitOptions = useMemo(() => {
const tiefe = new Map<string, number>();
const berechne = (u: OrgUnitNode, gesehen: Set<string>): number => {
const bekannt = tiefe.get(u.id);
if (bekannt !== undefined) return bekannt;
if (!u.parent_id || gesehen.has(u.id)) return 0;
gesehen.add(u.id);
const eltern = units.find((e) => e.id === u.parent_id);
const d = eltern ? berechne(eltern, gesehen) + 1 : 0;
tiefe.set(u.id, d);
return d;
};
const mitLeitung = new Set([
...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id),
...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id),
]);
return units.map((u) => ({
id: u.id,
name: u.name,
unit_type: u.unit_type,
depth: berechne(u, new Set()),
hasChief: mitLeitung.has(u.id),
}));
}, [units, employees, vacancies]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
@@ -56,14 +125,45 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
<LazyGraphOrgChart
tree={tree}
isExpanded={isExpanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
<ListNode
key={node.id}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
{einheit && (
<OrgUnitPanel
unit={einheit}
units={units}
unitOptions={unitOptions}
employees={employees}
vacancies={vacancies}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onClose={() => setGewaehlt(null)}
/>
)}
</div>
);
}
@@ -73,11 +173,17 @@ function ListNode({
depth,
expanded,
onToggle,
onAddUnit,
onDeleteUnit,
onSelectUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
onSelectUnit: (unitId: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
@@ -85,7 +191,7 @@ function ListNode({
return (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
className="group/zeile flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
@@ -108,14 +214,65 @@ function ListNode({
>
{node.label}
</Link>
) : node.unitId ? (
<button
type="button"
onClick={() => onSelectUnit(node.unitId!)}
aria-label={`Angaben zu ${node.label}`}
className="rounded text-left text-sm font-semibold text-ink hover:text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
{node.label}
</button>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
{/* Derselbe Knopf wie auf der Karte in der Grafik — beide Ansichten
kommen aus einem Baum, und eine Handlung, die es nur in einer von
beiden gibt, findet man in der anderen nie. */}
{node.unitId && !expandable && (
<button
type="button"
onClick={() => onDeleteUnit(node.unitId!)}
aria-label={`Einheit ${node.label} entfernen`}
title="Einheit entfernen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{node.unitId && (
<button
type="button"
onClick={() => onAddUnit(node.unitId!)}
aria-label={`Einheit unter ${node.label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
{open &&
node.children.map((child) => (
<ListNode
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
onSelectUnit={onSelectUnit}
/>
))}
</div>
);
}
@@ -203,11 +360,19 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
return {
id: key,
kind: "role",
unitId: unit.id,
label: `${unit.org_number} · ${unit.name}`,
// Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern
// ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle,
// die jemand besetzen müsste, und färbte obendrein den obersten Knoten
// als Vakanz ein. Überall sonst ist der Vermerk genau das und gehört
// hin — wie im Druck (PrintChart).
sublabel: chief
? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}`
: unit.unit_type === "Gesellschaft"
? undefined
: "Leitung vakant",
vacant: !chief,
vacant: !chief && unit.unit_type !== "Gesellschaft",
children: [...subUnits, ...titleGroups, ...vacancyNodes],
};
}

View File

@@ -2,7 +2,7 @@
import { ChevronRight, Printer } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { fmtDate } from "@/lib/format";
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
@@ -40,6 +40,16 @@ const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
];
/**
* Ob die Personalnummer mitgedruckt wird.
*
* Als Kontext und nicht als Eigenschaft durch den Baum: die Angabe betrifft
* eine einzige Zeile ganz unten (PersonZeile), müsste aber sonst durch jede
* Ebene der Seitenerzeugung durchgereicht werden — vier Signaturen für ein
* Häkchen. Gewünscht von Max am 30.09., ausdrücklich mit Wahlmöglichkeit.
*/
const NummerImDruck = createContext(false);
type Format = "a4" | "a3";
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
@@ -58,27 +68,58 @@ type Seite = {
titel: string;
unterzeile: string;
wurzel: PrintUnit;
/** Nur auf der Übersicht: die gewählten Bereiche statt aller Kinder. */
kinder?: PrintUnit[];
/** Wie viele Ebenen unter der Wurzel gezeichnet werden. */
ebenen: number;
mitPersonen: boolean;
};
/**
* Die Wurzel, beschnitten auf die Wege zu den gewählten Bereichen.
*
* Die Übersicht hängte die gewählten Bereiche unmittelbar unter die
* Gesellschaft. Das war richtig, solange sie dort auch hingen. Bei Manner
* liegt „CEO" dazwischen: die Übersicht hätte ihn stillschweigend
* übersprungen und die sechs C-Level direkt an die AG gehängt — ein
* Organigramm, das eine Ebene unterschlägt, ist schlimmer als keines.
*
* Gezeichnet wird deshalb der echte Weg von oben, nur ohne die Äste, in denen
* kein gewählter Bereich liegt. Damit wirkt das Ab- und Anwählen weiterhin
* bis in die Übersicht hinein.
*/
function beschneiden(unit: PrintUnit, ziele: Set<string>, mitKindern: boolean): PrintUnit | null {
if (ziele.has(unit.id)) {
// Am Ziel hört der Weg auf. Eine Ebene tiefer nur bei „bis Abteilung",
// wo die Übersicht das ganze Ergebnis ist.
return { ...unit, children: mitKindern ? unit.children.map((k) => ({ ...k, children: [] })) : [] };
}
const kinder = unit.children
.map((k) => beschneiden(k, ziele, mitKindern))
.filter((k): k is PrintUnit => k !== null);
return kinder.length > 0 ? { ...unit, children: kinder } : null;
}
/** Wie viele Ebenen unter dieser Einheit noch folgen. */
function ebenenVon(unit: PrintUnit): number {
return unit.children.length === 0 ? 0 : 1 + Math.max(...unit.children.map(ebenenVon));
}
function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asOf: string): Seite[] {
const stand = `Stand ${fmtDate(asOf)}`;
const seiten: Seite[] = [];
if (model.root) {
const ziele = new Set(bereiche.map((b) => b.id));
// Ist nichts gewählt, bleibt die Gesellschaft allein stehen.
const wurzel = beschneiden(model.root, ziele, tiefe === "abteilung") ?? { ...model.root, children: [] };
seiten.push({
key: "uebersicht",
titel: model.root.name,
unterzeile: `Organigramm · ${stand} · ${model.totals.people} Personen`,
wurzel: model.root,
kinder: bereiche,
// Bei „bis Abteilung" ist die Übersicht das ganze Ergebnis. Sonst folgen
// Detailblätter, und dann bleibt sie eine reine Bereichsübersicht.
ebenen: tiefe === "abteilung" ? 2 : 1,
wurzel,
// So tief, wie der beschnittene Baum reicht — nicht nach einer festen
// Zahl von Ebenen, denn wie viele zwischen Gesellschaft und Bereich
// liegen, hängt am Kunden.
ebenen: ebenenVon(wurzel),
mitPersonen: false,
});
}
@@ -129,6 +170,7 @@ function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asO
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
const [format, setFormat] = useState<Format>("a4");
const [tiefe, setTiefe] = useState<Tiefe>("team");
const [mitNummer, setMitNummer] = useState(false);
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
@@ -172,6 +214,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
}
return (
<NummerImDruck.Provider value={mitNummer}>
<div className="flex flex-col gap-6">
<style>{`
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
@@ -259,6 +302,15 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
</button>
))}
</div>
{/* Auf jeder Tiefe angeboten, nicht nur bei „mit Personen": die
Leitung einer Einheit steht auch auf den Blättern, die sonst
keine Namen führen. Ein Häkchen, das dort fehlte, sähe aus, als
gäbe es dort keine Namen. */}
<label className="mt-3 flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={mitNummer} onChange={(e) => setMitNummer(e.target.checked)} />
Personalnummer hinter den Namen drucken
</label>
</div>
<div className="mt-5">
@@ -381,7 +433,14 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
unterzeile={seite.unterzeile}
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
/>
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
{/* Die Kennung trägt `mitNummer` mit: die Zeilen werden
breiter, also muss der Massstab neu gemessen werden. Ohne
das bliebe der Wert des vorigen Laufs stehen, und das Blatt
liefe über den Rand. */}
<Skaliert
kennung={`${seite.key}|${format}|${tiefe}|${mitNummer}`}
onFaktor={(f) => meldeMassstab(seite.key, f)}
>
<Chart seite={seite} />
</Skaliert>
</Blatt>
@@ -390,6 +449,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
</>
)}
</div>
</NummerImDruck.Provider>
);
}
@@ -496,7 +556,7 @@ function Skaliert({
}
function Chart({ seite }: { seite: Seite }) {
const kinder = seite.kinder ?? seite.wurzel.children;
const kinder = seite.wurzel.children;
const zeigeKinder = seite.ebenen > 0 && kinder.length > 0;
return (
@@ -566,11 +626,15 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
</div>
<div className="px-2 py-1">
{/* Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt,
sondern ist das Ganze. „Leitung unbesetzt" las sich dort wie ein
Mangel, den jemand beheben müsste. Überall sonst ist es genau das
und gehört hin. */}
{unit.chief ? (
<PersonZeile p={unit.chief} leitung />
) : (
) : unit.unitType !== "Gesellschaft" ? (
<p className="text-[10px] italic text-ink-muted">Leitung unbesetzt</p>
)}
) : null}
{mitPersonen && unit.members.length > 0 && (
<div className={`mt-1 border-t border-border-subtle pt-1 ${zweispaltig ? "columns-2 gap-2" : ""}`}>
@@ -600,9 +664,15 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
}
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
const mitNummer = useContext(NummerImDruck);
return (
<p className="text-[10px] leading-tight">
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
{/* Die Personalnummer hinter den Namen und nicht in eine eigene Zeile:
auf einem Blatt mit zweihundert Kästen kostet jede zusätzliche Zeile
Massstab, und der entscheidet darüber, ob das Blatt noch lesbar ist.
Tabellenziffern, damit die Nummern untereinander stehen. */}
{mitNummer && <span className="text-ink-muted tabular-nums"> · {p.personnelNumber}</span>}
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
<br />
<span className="text-ink-muted">{p.jobTitle}</span>

View File

@@ -38,6 +38,13 @@ export type OrgUnitNode = {
name: string;
parent_id: string | null;
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
/**
* Nur die Struktursicht liest sie, für die Angaben zur Einheit. Der Druck
* kommt ohne sie aus und lädt sie deshalb auch nicht — optional statt
* überall mitgeschleppt.
*/
valid_from?: string;
valid_to?: string | null;
};
// Generic tree shape both EmployeeTree and PositionTree map their own data
@@ -66,5 +73,13 @@ export type ChartNode = {
coveringFor?: string;
/** Reports below this node in total, not just direct ones. */
totalReports?: number;
/**
* Die Organisationseinheit hinter dem Knoten — gesetzt nur in der
* Struktursicht und nur an Einheiten, nicht an Personen, Tätigkeitsgruppen
* oder Vakanzen. Daran hängt der Knopf „Einheit darunter anlegen“: aus der
* Knoten-ID (`unit-<uuid>`) liesse sich dasselbe herauslösen, aber dann
* wäre die ID plötzlich ein Datenformat und nicht mehr nur ein Schlüssel.
*/
unitId?: string;
children: ChartNode[];
};

View File

@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { PlanstelleZumAendern } from "@/lib/positions";
import type { UnitOption } from "./CreatePositionModal";
// Ändern statt löschen und neu anlegen.
@@ -33,7 +33,7 @@ export function EditPositionModal({
heute,
onClose,
}: {
position: OpenPositionResolved;
position: PlanstelleZumAendern;
units: UnitOption[];
kostenstellen: { id: string; code: string; name: string }[];
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
@@ -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 {

View File

@@ -6,13 +6,14 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { deleteReport, saveReport } from "@/actions/reports";
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { CONTROL_CLASS, DateField, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import {
anzahlKriterien,
AUSWAHL_KRITERIEN,
beschreibeKriterien,
JANEIN_KRITERIEN,
kriterienParameter,
ZEITRAUM_KRITERIEN,
@@ -28,6 +29,7 @@ import {
EVENT_TYPE_LABELS,
GROUP_LABELS,
MEASURE_LABELS,
messgroesseLabel,
parseStatuses,
REPORT_PRESETS,
sortKeysForDimension,
@@ -48,6 +50,21 @@ const EVENT_TYPES = EREIGNIS_AUSWAHL;
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
const VORSCHAU_NAMEN = 12;
/**
* Die Exporte, die in ein anderes System geladen werden.
*
* Sie stehen zusammen und getrennt vom Datenexport darüber, weil sie etwas
* anderes sind: der Datenexport gibt Zahlen heraus, die jemand ansieht, diese
* hier legen im Zielsystem Datensätze an. Nebeneinander in einer Reihe
* gleichartiger Karten war das nicht zu sehen — und eine versehentlich
* eingeschränkte Auswahl wirkt sich hier ganz anders aus.
*/
const ZIELSYSTEME: readonly { name: string; pfad: string }[] = [
{ name: "Honestly — Mitarbeiterbefragung", pfad: "/api/export/honestly" },
{ name: "Cornerstone — Lernsystem", pfad: "/api/export/cornerstone" },
{ name: "LOLYO — Mitarbeiter-App", pfad: "/api/export/lolyo" },
];
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
type OrgOption = { id: string; name: string };
@@ -171,17 +188,15 @@ function KriterienBlock({
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
<div className="grid grid-cols-2 gap-2">
<TextField
<DateField
label="Von"
dense
type="date"
value={criteria.zeitraum[k.key]?.von ?? ""}
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
/>
<TextField
<DateField
label="Bis"
dense
type="date"
value={criteria.zeitraum[k.key]?.bis ?? ""}
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
/>
@@ -212,7 +227,8 @@ function KriterienBlock({
</fieldset>
<p className="text-xs text-ink-muted">
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
Alle Kriterien gelten für den Bericht <em>und</em> für jeden Export dieser Seite — auch für den
Honestly- und den Cornerstone-Report. Was gerade eingestellt ist, steht auf der jeweiligen Karte.
</p>
</div>
)}
@@ -220,6 +236,27 @@ function KriterienBlock({
);
}
/**
* Was gerade in die Datei geht — Stichtag, Status, Einheit, Standort und jedes
* gesetzte Kriterium, dazu die Zeilenzahl.
*
* Auf den Karten stand nur der Status. Alles andere schränkt den Export
* genauso ein, steht aber eingeklappt weiter oben: eine Datei mit 40 statt 785
* Personen sah genauso aus wie eine vollständige. Für einen Bericht ist das
* ärgerlich, für ein Load-File ins Zielsystem ist es ein Datenstand, den dort
* niemand mehr hinterfragt.
*/
function AuswahlHinweis({ texte, anzahl }: { texte: string[]; anzahl: number }) {
return (
<div className="mb-2 rounded bg-surface px-2 py-1.5 text-xs text-ink-body">
<span className="font-semibold">Auswahl:</span> {texte.join(" · ")}
<span className="mt-0.5 block font-semibold">
{anzahl} {anzahl === 1 ? "Person" : "Personen"}
</span>
</div>
);
}
function formatValue(measure: Measure, value: number): string {
if (measure === "headcount") return String(Math.round(value));
if (measure === "fte") return value.toFixed(1);
@@ -355,13 +392,27 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
// Einheit und Standort stehen in der Adresszeile als Kennung; auf der Karte
// hilft nur der Name.
const einheitName = mode === "snapshot" ? units.find((u) => u.id === props.filters.division)?.name : undefined;
const standortName = mode === "snapshot" ? locations.find((l) => l.id === props.filters.location)?.name : undefined;
const auswahlTexte =
mode === "snapshot"
? [
`Stichtag ${fmtDate(props.asOf || todayIso())}`,
`Status ${statusExportLabel}`,
...(einheitName ? [`Einheit ${einheitName}`] : []),
...(standortName ? [`Standort ${standortName}`] : []),
...beschreibeKriterien(props.criteria),
]
: [];
const currentYear = new Date().getFullYear();
const defaultEventFrom = `${currentYear}-01-01`;
const defaultEventTo = `${currentYear}-12-31`;
const heading =
mode === "snapshot"
? `${MEASURE_LABELS[props.measure]} nach ${GROUP_LABELS[props.group]}`
? `${messgroesseLabel(props.measure, selectedStatuses)} nach ${GROUP_LABELS[props.group]}`
: `${props.eventType ? EVENT_TYPE_LABELS[props.eventType as HistoryEventType] : "Ereignisse"} nach ${EVENT_GROUP_LABELS[props.eventGroup]}`;
const totalDisplay = mode === "snapshot" ? formatValue(props.measure, total) : String(Math.round(total));
@@ -418,11 +469,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
/>
<div>
<div className="flex items-end gap-2">
<TextField
<DateField
label="Stichtag"
dense
className="flex-1"
type="date"
value={props.asOf || todayIso()}
onChange={(v) => updateParams({ asOf: v })}
/>
@@ -503,10 +553,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<legend className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</legend>
<div className="grid grid-cols-2 gap-2">
<div>
<TextField
<DateField
label="Von"
dense
type="date"
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
disabled={props.eventFilters.from === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ from: v })}
@@ -521,10 +570,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</Button>
</div>
<div>
<TextField
<DateField
label="Bis"
dense
type="date"
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
disabled={props.eventFilters.to === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ to: v })}
@@ -619,8 +667,8 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3>
{mode === "snapshot" ? (
<>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten, nicht nur die Kennzahl.</p>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
</>
) : (
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
@@ -637,6 +685,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div>
</div>
{/* Nur im Bestand: ein Zielsystem führt Personen, keine Ereignisse.
Dieselben Filter wie der vollständige Export darüber. */}
{mode === "snapshot" && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Exporte für Zielsysteme</h3>
<p className="mb-1 text-xs text-ink-muted">
Load-Dateien für Fremdsysteme, jede in deren eigenem Format. Für alle gilt dieselbe Auswahl.
</p>
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
<div className="flex flex-col divide-y divide-border-subtle">
{ZIELSYSTEME.map((z) => (
<div key={z.pfad} className="py-2 first:pt-0 last:pb-0">
<p className="mb-1.5 text-sm font-semibold text-ink">{z.name}</p>
<div className="flex gap-2">
<a href={exportHref(z.pfad, "csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={exportHref(z.pfad, "xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
))}
</div>
</div>
)}
{savedReports.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3>

View File

@@ -1,6 +1,13 @@
"use client";
import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";
import {
useId,
useState,
type ReactNode,
type SelectHTMLAttributes,
type InputHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
// Form primitives.
//
@@ -112,6 +119,74 @@ export function TextField({ label, required, hint, error, dense, className, valu
);
}
/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */
const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen.
*
* Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort
* ein blankes `<input>` steht. Zwei Fassungen derselben Regel laufen in diesem
* Projekt erfahrungsgemäss auseinander.
*/
export function istMeldbaresDatum(wert: string): boolean {
// Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen.
if (wert === "") return true;
if (!VOLLSTAENDIGES_DATUM.test(wert)) return false;
// Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt
// unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von
// einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse.
return Number(wert.slice(0, 4)) >= 1000;
}
/**
* Ein Datumsfeld, das Zwischenstände für sich behält.
*
* Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine
* Änderung heisst `router.push`, also neu laden und neu rendern. Ein
* `<input type="date">` meldet beim Tippen der Jahreszahl aber viermal:
* 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und
* jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im
* Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein
* fertiges Datum setzt.
*
* Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist
* — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des
* Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist.
*/
export function DateField({ value, onChange, onBlur, ...rest }: Omit<TextFieldProps, "type">) {
const [entwurf, setEntwurf] = useState(value);
const [zuletzt, setZuletzt] = useState(value);
// Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und
// nach dem Melden steht hier ohnehin schon dasselbe.
//
// Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt
// liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau
// den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn
// aus genau diesem Grund.
if (value !== zuletzt) {
setZuletzt(value);
setEntwurf(value);
}
return (
<TextField
{...rest}
type="date"
value={entwurf}
onChange={(v) => {
setEntwurf(v);
if (istMeldbaresDatum(v) && v !== value) onChange(v);
}}
onBlur={(e) => {
if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf);
onBlur?.(e);
}}
/>
);
}
type Option = { value: string; label: string; disabled?: boolean };
type SelectFieldProps = FieldShellProps &

View File

@@ -0,0 +1,849 @@
-- Die Firmen-E-Mail als eigenes, freiwilliges Feld.
--
-- `employees.email` ist die **private** Adresse (Migration 20260811140000).
-- Sie taugt nicht als Dienstadresse und darf auch nicht als solche benutzt
-- werden: der Honestly-Export geht an einen fremden Anbieter, der in unserem
-- Namen Einladungen verschickt. Die Spalte dort steht deshalb seit jeher
-- leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
-- Datenmodell fehlt. Hier ist sie.
--
-- Eindeutig, aber freiwillig: eine Dienstadresse gehört genau einer Person,
-- und mehrere Personen ohne Adresse stören einen Teilindex nicht, weil null
-- in Postgres nie gleich null ist.
--
-- Die drei Funktionen stehen unverändert aus 20260917120000 hier, ergänzt um
-- je eine Zeile für das neue Feld. Sie müssen mit, weil `create or replace`
-- die ganze Fassung ersetzt:
--
-- * app_feld_karte — sonst liesse sich der Eintrag in der
-- Historie nicht nachträglich richtigstellen.
-- * change_employee_data — sonst liesse sich das Feld gar nicht ändern.
-- * apply_due_pending_changes— sonst verfiele eine auf später datierte
-- Änderung still: sie stünde in der Historie
-- und käme nie in der Zeile an. Genau dieser
-- Fehler steckte schon einmal in der Gruppe
-- `role` (siehe 20260915120000).
--
-- Geschrieben wird wie beim Notfallkontakt über `case when ? then nullif`
-- und nicht über `coalesce`: eine Dienstadresse muss sich auch wieder
-- **entfernen** lassen, etwa wenn jemand das Haus verlässt. Mit `coalesce`
-- wäre das Leeren des Feldes nicht ausdrückbar.
alter table employees add column if not exists company_email text;
comment on column employees.company_email is
'Dienstliche E-Mail-Adresse. Freiwillig. Getrennt von email, das die private Adresse führt.';
create unique index if not exists employees_company_email_key on employees (company_email);
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
RETURNS uuid
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false);
v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false);
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, company_email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, mitarbeiterart, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis,
ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis,
hat_aufenthaltstitel, aufenthaltstitel_bis,
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
)
values (
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'company_email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce(nullif(payload->>'mitarbeiterart', ''), 'Standard'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false),
v_schutz,
-- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist:
-- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen
-- den ganzen Vorgang zurück.
case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
v_behindert,
case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end,
case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end,
case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end,
coalesce((payload->>'hat_aufenthaltstitel')::boolean, false),
case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false)
then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end,
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
nullif(payload->>'emergency_contact_name', ''),
nullif(payload->>'emergency_contact_phone', ''),
nullif(payload->>'emergency_contact_relation', '')
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$function$;
grant execute on function public.hire_employee(payload jsonb) to alpenwerk_app;
CREATE OR REPLACE FUNCTION public.rehire_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
v_alt employees%rowtype;
v_besetzt uuid;
v_schutz boolean;
v_behindert boolean;
begin
perform require_hr_admin();
select * into v_alt from employees where id = v_employee_id;
if v_alt.id is null then
raise exception 'Die Person existiert nicht.';
end if;
v_name := v_alt.first_name || ' ' || v_alt.last_name;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
-- Dieselben Prüfungen wie bei einer Neueinstellung. Sie fehlten hier, und
-- das hiess: eine Wiedereinstellung auf eine besetzte oder noch nicht
-- gültige Planstelle scheiterte erst an einem Index oder gar nicht.
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_date < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_date;
end if;
if v_bis is not null and v_date >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_date;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_date);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
-- Die beiden Kennzeichen zuerst: an ihnen hängen Felder, die ohne sie nicht
-- dastehen dürfen (chk_kuendigungsschutz_grund, chk_behinderung).
v_schutz := coalesce((payload->>'has_kuendigungsschutz')::boolean, v_alt.has_kuendigungsschutz);
v_behindert := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, v_alt.ist_beguenstigt_behindert);
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null,
austrittsart = null,
first_name = coalesce(payload->>'first_name', first_name),
last_name = coalesce(payload->>'last_name', last_name),
gender = coalesce((payload->>'gender')::gender_type, gender),
birth_date = coalesce((payload->>'birth_date')::date, birth_date),
sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(payload->>'nationality', nationality),
email = case when payload ? 'email' then nullif(payload->>'email', '') else email end,
company_email = case when payload ? 'company_email' then nullif(payload->>'company_email', '') else company_email end,
phone = case when payload ? 'phone' then nullif(payload->>'phone', '') else phone end,
address = coalesce(payload->>'address', address),
postal_code = coalesce(payload->>'postal_code', postal_code),
city = coalesce(payload->>'city', city),
address_country = coalesce(payload->>'address_country', address_country),
location_id = coalesce((payload->>'location_id')::uuid, location_id),
title_prefix = case when payload ? 'title_prefix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}')
else title_prefix end,
title_suffix = case when payload ? 'title_suffix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}')
else title_suffix end,
emergency_contact_name = case when payload ? 'emergency_contact_name' then nullif(payload->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when payload ? 'emergency_contact_phone' then nullif(payload->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when payload ? 'emergency_contact_relation' then nullif(payload->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel)
then case when payload ? 'aufenthaltstitel_bis' then nullif(payload->>'aufenthaltstitel_bis', '')::date else aufenthaltstitel_bis end
else null
end,
-- Die Tätigkeit kommt von der Planstelle, nicht aus dem payload: sie
-- steht am Platz, den die Person einnimmt, und nicht an ihr.
job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
employment_type = coalesce((payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((payload->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((payload->>'contract_type')::contract_type, contract_type),
contract_end_date = case when payload ? 'contract_end_date' then nullif(payload->>'contract_end_date', '')::date else contract_end_date end,
paygrade = coalesce((payload->>'paygrade')::paygrade_type, paygrade),
source = coalesce((payload->>'source')::source_type, source),
worker_type = coalesce((payload->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(payload->>'mitarbeiterart', ''), mitarbeiterart),
collective_agreement = coalesce((payload->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when payload ? 'work_days'
then coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), work_days)
else work_days end,
is_betriebsrat = coalesce((payload->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen),
dienstwagen_art = case
when coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen)
then coalesce(nullif(payload->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
is_laterale_fuehrung = coalesce((payload->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((payload->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = v_schutz,
kuendigungsschutz_grund = case when v_schutz then
case when payload ? 'kuendigungsschutz_grund' then nullif(payload->>'kuendigungsschutz_grund', '') else kuendigungsschutz_grund end
else null end,
kuendigungsschutz_ab = case when v_schutz then
case when payload ? 'kuendigungsschutz_ab' then nullif(payload->>'kuendigungsschutz_ab', '')::date else kuendigungsschutz_ab end
else null end,
kuendigungsschutz_bis = case when v_schutz then
case when payload ? 'kuendigungsschutz_bis' then nullif(payload->>'kuendigungsschutz_bis', '')::date else kuendigungsschutz_bis end
else null end,
ist_beguenstigt_behindert = v_behindert,
behinderung_grad = case when v_behindert then
case when payload ? 'behinderung_grad' then nullif(payload->>'behinderung_grad', '')::integer else behinderung_grad end
else null end,
behinderung_ab = case when v_behindert then
case when payload ? 'behinderung_ab' then nullif(payload->>'behinderung_ab', '')::date else behinderung_ab end
else null end,
behinderung_bis = case when v_behindert then
case when payload ? 'behinderung_bis' then nullif(payload->>'behinderung_bis', '')::date else behinderung_bis end
else null end
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt',
'Wiedereinstellung zum ' || v_date || ' auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id,
'Wiedereintritt am ' || v_date);
end;
$function$;
grant execute on function public.rehire_employee(payload jsonb) to alpenwerk_app;
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Firmen-E-Mail', jsonb_build_array('company_email', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'),
'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Mitarbeiterart', jsonb_build_array('mitarbeiterart', 'text', 'role'),
'Besetzungsart', jsonb_build_array('source', 'source_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'),
'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'),
-- 'integer' und nicht 'numeric': der Typ geht in update_history_entry
-- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über
-- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete
-- dabei — der Grad ist eine ganze Zahl aus einem Bescheid.
'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'),
'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'),
'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
-- und Rückkehr ihre eigenen Vorgänge haben.
'Status', jsonb_build_array('status', 'employment_status', null),
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
);
$function$;
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'company_email' then v_person_changes := app_aenderung(v_person_changes, 'Firmen-E-Mail', v_old.company_email, v_person->>'company_email'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
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_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'mitarbeiterart' then v_contract_changes := app_aenderung(v_contract_changes, 'Mitarbeiterart', v_old.mitarbeiterart, v_role->>'mitarbeiterart'); end if;
if v_role ? 'source' then v_contract_changes := app_aenderung(v_contract_changes, 'Besetzungsart', v_old.source::text, v_role->>'source'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
company_email = case when v_person ? 'company_email' then nullif(v_person->>'company_email', '') else company_email end,
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
aufenthaltstitel_bis = case
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_person ? 'aufenthaltstitel_bis'
then nullif(v_person->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
-- Ohne nullif: die Spalte ist NOT NULL, ein leerer Text waere kein
-- geloeschter Wert, sondern ein Wert, den chk_mitarbeiterart abweist.
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund'
then nullif(v_role->>'kuendigungsschutz_grund','')
else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab'
then nullif(v_role->>'kuendigungsschutz_ab','')::date
else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
-- ── Die Übernahme ────────────────────────────────────────────────
--
-- Nur in dieser einen Richtung. Wer von extern nach intern wechselt, wird
-- übernommen; der umgekehrte Weg ist kein Vorgang, den es fachlich gibt.
--
-- Das Ereignis entsteht **zusätzlich** zur Vertragsänderung darüber und
-- nicht an ihrer Stelle: die eine hält fest, dass ein Feld sich geändert
-- hat und worauf (und lässt sich darüber zurücknehmen), das andere, dass
-- dieser Wechsel eine Übernahme war — und nur das lässt sich zählen.
--
-- `v_effective_date`, nicht `now()`: eine auf später datierte Übernahme
-- gehört in die Akte auf den Tag, an dem sie gilt. Bei einer sofortigen
-- ist das heute.
if v_role ? 'source' and v_old.source::text = 'Extern' and v_role->>'source' = 'Intern' then
insert into employee_history (employee_id, event_date, event_type, description, pending_id)
values (v_employee_id, v_effective_date, 'Übernahme',
'Übernahme von extern auf intern, wirksam ab ' || v_effective_date, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Übernahme', v_name, v_employee_id,
'Von extern auf intern, wirksam ab ' || v_effective_date);
end if;
end;
$function$;
-- ═══ Der Nachtlauf holt nach, was er bisher liegenliess ═══
--
-- Eine auf später datierte Änderung landet als 'contract_change' in
-- pending_org_changes; apply_due_pending_changes schreibt sie am Stichtag in
-- die Zeile. Bisher schrieb es dabei **nur** Person und Vertrag zurück — die
-- ganze Gruppe `role` fehlte. Betriebsrat, Dienstwagen, Kollektivvertrag,
-- Arbeitstage, Teilzeitvariante und der Kündigungsschutz wurden also erfasst,
-- in der Historie vermerkt, als Vertragsänderung protokolliert — und am
-- Stichtag nicht angewendet. Sichtbar wurde das nie: der Eintrag in der Akte
-- steht da, nur die Spalte blieb alt.
--
-- Das ist älter als diese Migration und gehört streng genommen nicht zu ihr.
-- Die neuen Felder erben den Fehler aber unmittelbar, und ihn nur für sie zu
-- beheben hiesse, zwei Klassen von Feldern mit zwei Verhaltensweisen zu
-- hinterlassen. Deshalb hier vollständig.
--
-- Die Reihenfolge der Zuweisungen entspricht der in change_employee_data;
-- dieselben Kopplungen (fällt das Kennzeichen, fällt das Datum) gelten hier
-- genauso, sonst wiese eine Bedingung den Nachtlauf zurück.
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update employees set job_title = coalesce(v_rec.payload->>'new_title', job_title)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
grant execute on function public.change_employee_data(payload jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employees' and column_name = 'company_email'
) then
raise exception 'Die Spalte company_email fehlt.';
end if;
if pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure) not like '%company_email%' then
raise exception 'change_employee_data kennt die Firmen-E-Mail nicht — sie liesse sich nicht aendern.';
end if;
-- Der Nachtlauf ist die Stelle, an der so etwas schon einmal still
-- verschwunden ist: erfasst, in der Historie sichtbar, nie angekommen.
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%company_email%' then
raise exception 'Der Nachtlauf kennt die Firmen-E-Mail nicht — eine auf spaeter datierte Aenderung verfiele.';
end if;
if app_feld_karte() -> 'Firmen-E-Mail' is null then
raise exception 'Die Feldkarte kennt die Firmen-E-Mail nicht — der Eintrag in der Historie waere nicht korrigierbar.';
end if;
-- Ohne das nimmt der Einstellungsassistent das Feld entgegen und wirft es
-- weg: das Formular zeigte eine Adresse, gespeichert waere keine.
if pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure) not like '%company_email%' then
raise exception 'hire_employee kennt die Firmen-E-Mail nicht — bei der Einstellung ginge sie verloren.';
end if;
-- Der Wiedereintritt teilt sich den Schritt „Person" mit der Einstellung:
-- das Feld steht dort im Formular, ob diese Funktion es kennt oder nicht.
if pg_get_functiondef('public.rehire_employee(jsonb)'::regprocedure) not like '%company_email%' then
raise exception 'rehire_employee kennt die Firmen-E-Mail nicht — beim Wiedereintritt ginge sie verloren.';
end if;
end $$;

View File

@@ -0,0 +1,501 @@
-- Eine geleerte freiwillige Angabe ist null, nicht die leere Zeichenkette.
--
-- Gemeldet als Fehlermeldung beim Speichern in „Daten ändern":
-- `duplicate key value violates unique constraint "employees_email_key"`,
-- sobald bei einer zweiten Person das Feld „E-Mail (privat)" leer blieb.
--
-- ═══ Warum das erst jetzt auffiel ══════════════════════════════════
--
-- `email` war bis 20260811140000 NOT NULL: jede Person hatte eine, das Feld
-- im Formular war nie leer. Seither ist die Angabe freiwillig — aber
-- change_employee_data schrieb weiter `coalesce(v_person->>'email', email)`.
-- Ein leeres Formularfeld kommt als "" an, und "" ist nicht null: es landete
-- als leere Zeichenkette in der Spalte. Beim ersten Mal ging das gut, beim
-- zweiten schlug der eindeutige Index zu, denn "" ist gleich "" — während
-- null in Postgres nie gleich null ist und deshalb beliebig oft vorkommen
-- darf.
--
-- Vorher fiel es niemandem auf, weil die Spieldaten durchweg Adressen
-- trugen. Die 784 übernommenen Personen tragen keine, und damit ist jede
-- zweite Änderung an irgendeiner von ihnen darauf gelaufen.
--
-- ═══ Was sich ändert ═══════════════════════════════════════════════
--
-- Dieselbe Form wie beim Notfallkontakt und bei der Firmen-E-Mail:
-- `case when ? then nullif(…, '') else … end`. Der Unterschied zu coalesce
-- ist beides Mal wichtig:
--
-- * fehlt der Schlüssel, bleibt der alte Wert — wie bisher;
-- * steht er mit leerem Wert da, wird das Feld **geleert** statt auf ""
-- gesetzt. Das ist die Absicht, die jemand im Formular ausdrückt, wenn
-- er eine Angabe herauslöscht.
--
-- Betroffen sind nur die freiwilligen Textfelder. first_name, last_name,
-- gender, birth_date und nationality sind NOT NULL; dort wäre nullif ein
-- Fehler und keine Verbesserung.
--
-- Der Nachtlauf bekommt dieselbe Behandlung: eine auf später datierte
-- Änderung liefe sonst in denselben Index, nur um drei Uhr früh und ohne
-- jemanden, dem die Meldung angezeigt würde.
-- Was schon als leere Zeichenkette dasteht, wird aufgeräumt. Andernfalls
-- blockierte die eine bereits gespeicherte "" weiterhin jede weitere Person
-- ohne private Adresse.
update employees set
sv_nummer = nullif(sv_nummer, ''),
address = nullif(address, ''),
postal_code = nullif(postal_code, ''),
city = nullif(city, ''),
address_country = nullif(address_country, ''),
email = nullif(email, ''),
phone = nullif(phone, ''),
company_email = nullif(company_email, '')
where sv_nummer = '' or address = '' or postal_code = '' or city = ''
or address_country = '' or email = '' or phone = '' or company_email = '';
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'company_email' then v_person_changes := app_aenderung(v_person_changes, 'Firmen-E-Mail', v_old.company_email, v_person->>'company_email'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
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_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'mitarbeiterart' then v_contract_changes := app_aenderung(v_contract_changes, 'Mitarbeiterart', v_old.mitarbeiterart, v_role->>'mitarbeiterart'); end if;
if v_role ? 'source' then v_contract_changes := app_aenderung(v_contract_changes, 'Besetzungsart', v_old.source::text, v_role->>'source'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = case when v_person ? 'sv_nummer' then nullif(v_person->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_person->>'nationality', nationality),
address = case when v_person ? 'address' then nullif(v_person->>'address', '') else address end,
postal_code = case when v_person ? 'postal_code' then nullif(v_person->>'postal_code', '') else postal_code end,
city = case when v_person ? 'city' then nullif(v_person->>'city', '') else city end,
address_country = case when v_person ? 'address_country' then nullif(v_person->>'address_country', '') else address_country end,
email = case when v_person ? 'email' then nullif(v_person->>'email', '') else email end,
phone = case when v_person ? 'phone' then nullif(v_person->>'phone', '') else phone end,
company_email = case when v_person ? 'company_email' then nullif(v_person->>'company_email', '') else company_email end,
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
aufenthaltstitel_bis = case
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_person ? 'aufenthaltstitel_bis'
then nullif(v_person->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
-- Ohne nullif: die Spalte ist NOT NULL, ein leerer Text waere kein
-- geloeschter Wert, sondern ein Wert, den chk_mitarbeiterart abweist.
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund'
then nullif(v_role->>'kuendigungsschutz_grund','')
else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab'
then nullif(v_role->>'kuendigungsschutz_ab','')::date
else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
-- ── Die Übernahme ────────────────────────────────────────────────
--
-- Nur in dieser einen Richtung. Wer von extern nach intern wechselt, wird
-- übernommen; der umgekehrte Weg ist kein Vorgang, den es fachlich gibt.
--
-- Das Ereignis entsteht **zusätzlich** zur Vertragsänderung darüber und
-- nicht an ihrer Stelle: die eine hält fest, dass ein Feld sich geändert
-- hat und worauf (und lässt sich darüber zurücknehmen), das andere, dass
-- dieser Wechsel eine Übernahme war — und nur das lässt sich zählen.
--
-- `v_effective_date`, nicht `now()`: eine auf später datierte Übernahme
-- gehört in die Akte auf den Tag, an dem sie gilt. Bei einer sofortigen
-- ist das heute.
if v_role ? 'source' and v_old.source::text = 'Extern' and v_role->>'source' = 'Intern' then
insert into employee_history (employee_id, event_date, event_type, description, pending_id)
values (v_employee_id, v_effective_date, 'Übernahme',
'Übernahme von extern auf intern, wirksam ab ' || v_effective_date, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Übernahme', v_name, v_employee_id,
'Von extern auf intern, wirksam ab ' || v_effective_date);
end if;
end;
$function$;
-- ═══ Der Nachtlauf holt nach, was er bisher liegenliess ═══
--
-- Eine auf später datierte Änderung landet als 'contract_change' in
-- pending_org_changes; apply_due_pending_changes schreibt sie am Stichtag in
-- die Zeile. Bisher schrieb es dabei **nur** Person und Vertrag zurück — die
-- ganze Gruppe `role` fehlte. Betriebsrat, Dienstwagen, Kollektivvertrag,
-- Arbeitstage, Teilzeitvariante und der Kündigungsschutz wurden also erfasst,
-- in der Historie vermerkt, als Vertragsänderung protokolliert — und am
-- Stichtag nicht angewendet. Sichtbar wurde das nie: der Eintrag in der Akte
-- steht da, nur die Spalte blieb alt.
--
-- Das ist älter als diese Migration und gehört streng genommen nicht zu ihr.
-- Die neuen Felder erben den Fehler aber unmittelbar, und ihn nur für sie zu
-- beheben hiesse, zwei Klassen von Feldern mit zwei Verhaltensweisen zu
-- hinterlassen. Deshalb hier vollständig.
--
-- Die Reihenfolge der Zuweisungen entspricht der in change_employee_data;
-- dieselben Kopplungen (fällt das Kennzeichen, fällt das Datum) gelten hier
-- genauso, sonst wiese eine Bedingung den Nachtlauf zurück.
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update employees set job_title = coalesce(v_rec.payload->>'new_title', job_title)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
grant execute on function public.change_employee_data(payload jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_leer int;
begin
-- Kein coalesce mehr auf den freiwilligen Feldern: genau das schrieb "".
if v_chg like '%coalesce(v_person->>''email''%' then
raise exception 'change_employee_data schreibt die E-Mail weiter mit coalesce — eine geleerte Angabe wuerde "" statt null.';
end if;
if v_lauf like '%coalesce(v_rec.payload->''person''->>''email''%' then
raise exception 'Der Nachtlauf schreibt die E-Mail weiter mit coalesce.';
end if;
if v_chg not like '%nullif(v_person->>''phone''%' then
raise exception 'change_employee_data schreibt die Telefonnummer nicht mit nullif.';
end if;
select count(*) into v_leer from employees
where sv_nummer = '' or address = '' or postal_code = '' or city = ''
or address_country = '' or email = '' or phone = '' or company_email = '';
if v_leer > 0 then
raise exception 'Es stehen noch % Zeilen mit leeren Zeichenketten in freiwilligen Feldern.', v_leer;
end if;
end $$;

View File

@@ -0,0 +1,129 @@
-- Die Anmerkung beim Austritt wird wieder aufgeschrieben.
--
-- Gefragt vom Kunden: „beim Austritt gibt es eine Anmerkung — wo sieht man
-- die nachher?" Die Antwort war: nirgends. Das Formular sammelt sie ein, die
-- Server Action reicht sie durch, und terminate_employee liess sie fallen.
--
-- Bis 20260814100000 stand sie in der Beschreibung des Ereignisses:
--
-- 'Austritt (' || exit_reason || ')'
-- || case when payload->>'note' <> '' then ' — ' || note else '' end
--
-- Beim Umschreiben der Funktion für den Nichtantritt ist der zweite Teil
-- verlorengegangen, und bei der Austrittsart (20260917100000) wurde die
-- bereits verkürzte Fassung weitergereicht. Genau davor warnt die
-- Selbstprüfung jener Migration im Kommentar — „der Fehler, den ein
-- `create or replace` aus einer alten Vorlage leise macht" —, und für die
-- Anmerkung ist er trotzdem passiert. Deshalb steht unten eine Prüfung, die
-- ihn beim nächsten Mal abfängt.
--
-- Die Anmerkung geht in **beide** Einträge: in die Personalakte, wo sie
-- hingehört, und ins Protokoll, wo ohne sie nicht nachvollziehbar wäre,
-- was jemand beim Austritt festgehalten hat. Angehängt an die fertige
-- Beschreibung und nicht in einen der Zweige: der Nichtantritt darf sie
-- genauso tragen.
--
-- Bestehende Einträge bleiben, wie sie sind. Der Text ist nicht mehr da; er
-- wurde nie geschrieben, und es gibt nichts, woraus er sich rekonstruieren
-- liesse.
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
v_entry date;
v_art text := nullif(payload->>'austrittsart', '');
v_note text := nullif(btrim(payload->>'note'), '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, entry_date into v_name, v_entry
from employees where id = v_employee_id;
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
if v_no_show then
v_exit := v_entry;
end if;
update employees set
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
-- denn es gibt keinen Lauf, der sie später nachzieht.
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason',
austrittsart = v_art
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
if v_no_show then
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
-- Intervall. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
delete from position_assignments
where employee_id = v_employee_id and valid_to is null;
else
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt',
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')'
|| case when v_art is not null then ', ' || v_art else '' end end
|| case when v_note is not null then ' — ' || v_note else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id,
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt am ' || v_exit || case when v_art is not null then ', ' || v_art else '' end end
|| case when v_note is not null then ' — ' || v_note else '' end);
end;
$function$;
grant execute on function public.terminate_employee(payload jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_term text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
begin
if v_term not like '%v_note%' then
raise exception 'terminate_employee schreibt die Anmerkung nicht — das Feld im Formular ginge wieder ins Leere.';
end if;
-- Was diese Funktion schon einmal verloren hat, darf sie nicht wieder
-- verlieren. Dieselben Prüfungen wie in 20260917100000, hier um die
-- Anmerkung ergänzt.
if v_term not like '%austrittsart%' then
raise exception 'terminate_employee schreibt die Austrittsart nicht mehr';
end if;
if v_term not like '%v_no_show%' or v_term not like '%delete from position_assignments%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr';
end if;
if position('SET search_path' in v_term) = 0 then
raise exception 'terminate_employee hat keinen festen search_path mehr';
end if;
if v_term like '%auth.uid()%' then
raise exception 'terminate_employee ruft wieder auth.uid()';
end if;
end
$$;

View File

@@ -0,0 +1,328 @@
-- Die Beförderung kann die Planstelle wechseln — und der Nachtlauf führt
-- beides aus.
--
-- ═══ 1. Beförderung: dieselbe Stelle oder eine neue ════════════════
--
-- Bisher änderte eine Beförderung nur Bezeichnung und Verwendungsgruppe; die
-- Planstelle blieb, wo sie war. Gewünscht ist die Wahl: dieselbe Planstelle
-- oder eine neue, und dann aus den freien — wie die Zielplanstelle bei der
-- Versetzung.
--
-- Die neue Stelle muss frei sein, mit derselben Meldung wie dort. Ein
-- Sonderfall ist erlaubt: die Person sitzt bereits darauf. Dann ist es keine
-- Bewegung, sondern eine Beförderung auf der eigenen Stelle, und ein „bereits
-- besetzt" wäre eine Falschaussage über sie selbst.
--
-- `job_title` kommt aus dem Formular und nicht aus dem Job der Zielstelle:
-- die Spalte darf abweichen (so steht es auch in der Importvorlage), und die
-- Oberfläche schlägt den Titel der gewählten Stelle ohnehin vor.
--
-- Nebenbei zwei Altlasten dieser Funktion, die beim Neuschreiben ohnehin
-- anfielen: `auth.uid()` weicht `app_current_user_id()` — die Anwendungsrolle
-- darf das Schema `auth` nicht —, und der `search_path` steht jetzt fest.
--
-- ═══ 2. Der Nachtlauf bewegte niemanden ════════════════════════════
--
-- Beim Aufräumen dieser Stelle ist ein zweiter, älterer Fehler aufgefallen.
--
-- `transfer_employee` schreibt für ein künftiges Datum einen Eintrag in
-- pending_org_changes mit `target_position_id`. Der Nachtlauf hat dort zur
-- Umstellung auf Planstellen (20260727120200) die Besetzung umgehängt. In der
-- zuletzt gültigen Fassung stand nur noch:
--
-- update employees set job_title = coalesce(payload->>'new_title', ...)
--
-- also die Fassung von **vor** jener Umstellung — und `new_title` kommt in
-- diesem payload gar nicht vor. Eine auf später datierte Versetzung wurde am
-- Stichtag auf `applied` gesetzt und bewegte niemanden: die Person blieb auf
-- ihrer alten Planstelle, im Organigramm unverändert, ohne dass irgendwo eine
-- Meldung entstand.
--
-- Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
-- neu geschrieben. Dieselbe Falle wie bei der Gruppe `role`
-- (20260915120000) und bei der Anmerkung zum Austritt (20260923180000). Die
-- Selbstprüfung unten prüft deshalb nicht mehr nur das neue Verhalten,
-- sondern beide Zweige auf das, was sie anfassen müssen.
create or replace function promote_employee(payload jsonb)
returns void
language plpgsql
set search_path to 'public', 'pg_temp'
as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_position uuid := nullif(payload->>'target_position_id', '')::uuid;
v_old_paygrade paygrade_type;
v_name text;
v_details text;
v_besetzt uuid;
v_nummer text;
begin
perform require_hr_admin();
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name
from employees where id = v_employee_id;
if v_position is not null then
select position_number into v_nummer from om_positions where id = v_position;
if v_nummer is null then
raise exception 'Die gewählte Planstelle existiert nicht.';
end if;
-- Wie bei der Versetzung. Die eigene Stelle ist ausgenommen: darauf sitzt
-- die Person schon, und das ist kein Hindernis für ihre Beförderung.
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position and pa.valid_to is null;
if v_besetzt is not null and v_besetzt <> v_employee_id then
raise exception 'Die Zielplanstelle ist bereits besetzt.';
end if;
end if;
v_details := 'Neue Position: ' || (payload->>'new_title');
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
v_details := v_details || ', neue Verwendungsgruppe: ' || (payload->>'new_paygrade');
end if;
if v_nummer is not null then
v_details := v_details || ', neue Planstelle ' || v_nummer;
end if;
if v_effective_date <= current_date then
update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
-- Nur, wenn sich die Stelle wirklich ändert: sonst entstünde eine
-- Besetzung, die am selben Tag endet und beginnt, und die Historie der
-- Planstelle hätte eine Naht ohne Anlass.
if v_position is not null and v_besetzt is distinct from v_employee_id then
update position_assignments set valid_to = v_effective_date
where employee_id = v_employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position, v_employee_id, v_effective_date);
end if;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'promotion', v_effective_date,
jsonb_build_object(
'new_title', payload->>'new_title',
'new_paygrade', payload->>'new_paygrade',
'target_position_id', v_position));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;
grant execute on function public.promote_employee(payload jsonb) to alpenwerk_app;
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
-- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen
-- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt
-- — die Fassung von vor der Umstellung auf Planstellen. Eine auf
-- später datierte Versetzung galt damit am Stichtag als angewendet
-- und bewegte niemanden.
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
-- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig
-- stünde am Stichtag der neue Titel an der Person und sie säsze
-- weiterhin auf der alten Stelle — im Organigramm unverändert.
if nullif(v_rec.payload->>'target_position_id', '') is not null then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
end if;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_pro text := pg_get_functiondef('public.promote_employee(jsonb)'::regprocedure);
v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
begin
if v_pro not like '%target_position_id%' then
raise exception 'promote_employee kennt die Zielplanstelle nicht.';
end if;
if v_pro like '%auth.uid()%' then
raise exception 'promote_employee ruft wieder auth.uid() — die Anwendungsrolle darf das Schema auth nicht.';
end if;
if position('SET search_path' in v_pro) = 0 then
raise exception 'promote_employee hat keinen festen search_path.';
end if;
-- Beide Zweige müssen die Besetzung anfassen. Genau das ist hier schon
-- einmal still verlorengegangen; eine Prüfung auf „kommt vor" reicht
-- nicht, weil die Funktion an anderer Stelle ohnehin damit arbeitet.
if v_lauf not like '%target_position_id%' then
raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht — geplante Versetzungen bewegten niemanden.';
end if;
if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then
raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um (Versetzung und Befoerderung).';
end if;
end
$$;

View File

@@ -0,0 +1,93 @@
-- Angehörige lassen sich bearbeiten.
--
-- Bisher gab es nur Hinzufügen und Entfernen. Ein Tippfehler im Namen oder
-- ein falsches Geburtsdatum liess sich nur beheben, indem man die Person
-- löschte und neu anlegte — zwei Einträge in der Akte für eine Korrektur, und
-- der erste sagt „entfernt", was nicht stimmt.
--
-- Anders als Hinzufügen und Entfernen **ohne Stichtag**: eine Berichtigung
-- gilt rückwirkend, denn der Wert war schon vorher falsch. Ein „wirksam ab"
-- hiesse, die Person habe bis dahin anders geheissen. Wer eine tatsächliche
-- Änderung zum Stichtag abbilden will — jemand heiratet und heisst fortan
-- anders —, entfernt und legt neu an; dafür sind die beiden Vorgänge da.
--
-- Der Eintrag in der Akte nennt deshalb Vorher und Nachher, nicht ein Datum.
create or replace function update_employee_dependent(payload jsonb)
returns void
language plpgsql
set search_path to 'public', 'pg_temp'
as $$
declare
v_alt employee_dependents%rowtype;
v_employee_name text;
v_neu_name text := (payload->>'first_name') || ' ' || (payload->>'last_name');
v_alt_name text;
begin
perform require_hr_admin();
select * into v_alt from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
v_alt_name := v_alt.first_name || ' ' || v_alt.last_name;
select first_name || ' ' || last_name into v_employee_name
from employees where id = v_alt.employee_id;
-- Nur die Felder, die mitkommen. Die SV-Nummer ist freiwillig und muss
-- sich leeren lassen, deshalb `case when ? then nullif` statt coalesce —
-- dieselbe Form wie bei den freiwilligen Feldern der Person
-- (20260923160000).
update employee_dependents set
first_name = coalesce(payload->>'first_name', first_name),
last_name = coalesce(payload->>'last_name', last_name),
relationship = coalesce(payload->>'relationship', relationship),
birth_date = coalesce((payload->>'birth_date')::date, birth_date),
sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end
where id = v_alt.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_alt.employee_id, current_date, 'Stammdatenänderung',
'Angehörige:r berichtigt: ' || v_alt_name || ' (' || v_alt.relationship || ') → '
|| v_neu_name || ' (' || (payload->>'relationship') || ')'
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (
app_current_user_id(), current_actor_name(), 'Angehörige:r berichtigt', v_employee_name, v_alt.employee_id,
v_alt_name || ' → ' || v_neu_name,
app_aenderung(
app_aenderung(
app_aenderung(
app_aenderung('[]'::jsonb, 'Name', v_alt_name, v_neu_name),
'Verhältnis', v_alt.relationship, payload->>'relationship'),
'Geburtsdatum', v_alt.birth_date::text, payload->>'birth_date'),
'SV-Nummer', v_alt.sv_nummer, nullif(payload->>'sv_nummer', ''))
);
end;
$$;
grant execute on function public.update_employee_dependent(payload jsonb) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.update_employee_dependent(jsonb)'::regprocedure);
begin
if v_def not like '%require_hr_admin%' then
raise exception 'update_employee_dependent prueft die Rechte nicht.';
end if;
if position('SET search_path' in v_def) = 0 then
raise exception 'update_employee_dependent hat keinen festen search_path.';
end if;
if v_def like '%auth.uid()%' then
raise exception 'update_employee_dependent ruft auth.uid().';
end if;
if v_def not like '%insert into audit_log%' then
raise exception 'update_employee_dependent schreibt kein Protokoll.';
end if;
end
$$;

View File

@@ -0,0 +1,244 @@
-- Eine datierte Stammdatenänderung verlor fünf Felder.
--
-- change_employee_data schreibt bei einem Stichtag in der Zukunft nicht in
-- die Zeile, sondern legt einen Eintrag in pending_org_changes an; am
-- Stichtag trägt ihn apply_due_pending_changes nach. Dessen Zweig
-- `contract_change` kannte fünf Spalten nicht, die der sofortige Weg
-- schreibt:
--
-- emergency_contact_name, emergency_contact_phone,
-- emergency_contact_relation, title_prefix, title_suffix
--
-- Wer also den Notfallkontakt oder einen akademischen Titel zu einem
-- künftigen Datum erfasste, sah den Vorgang in der Personalakte, und am
-- Stichtag geschah nichts. Der Eintrag wurde auf `applied` gesetzt, die
-- Zeile blieb unverändert, eine Meldung gab es nicht.
--
-- Gefunden durch einen Abgleich der beiden Wege: 44 Spalten schreibt
-- change_employee_data sofort, 39 der Nachtlauf. Das ist dieselbe Lücke wie
-- bei der Gruppe `role` (20260915120000) und bei der Besetzung im Zweig
-- `transfer` (20260924100000) — und derselbe Weg, sie zu finden.
--
-- Die Selbstprüfung unten vergleicht beide Listen von jetzt an bei jedem
-- Lauf der Migration, statt einzelne Namen abzufragen.
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
-- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen
-- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt
-- — die Fassung von vor der Umstellung auf Planstellen. Eine auf
-- später datierte Versetzung galt damit am Stichtag als angewendet
-- und bewegte niemanden.
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
-- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig
-- stünde am Stichtag der neue Titel an der Person und sie säsze
-- weiterhin auf der alten Stelle — im Organigramm unverändert.
if nullif(v_rec.payload->>'target_position_id', '') is not null then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
end if;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
-- Der Notfallkontakt und die Titel fehlten hier. Eine auf später
-- datierte Änderung stand damit in der Akte, kam aber nie an der
-- Person an: change_employee_data schreibt sie sofort, dieser Zweig
-- kannte sie nicht. Dieselbe Lücke wie einst bei der Gruppe `role`
-- (20260915120000).
emergency_contact_name = case when v_rec.payload->'person' ? 'emergency_contact_name'
then nullif(v_rec.payload->'person'->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_rec.payload->'person' ? 'emergency_contact_phone'
then nullif(v_rec.payload->'person'->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_rec.payload->'person' ? 'emergency_contact_relation'
then nullif(v_rec.payload->'person'->>'emergency_contact_relation', '') else emergency_contact_relation end,
-- Wie work_days weiter unten: eine leere Liste ist eine Angabe, kein
-- fehlender Wert, deshalb coalesce auf '{}' statt auf den alten Stand.
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}')
else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}')
else title_suffix end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
-- Selbstprüfung: beide Wege müssen dieselben Spalten der Person kennen.
do $$
declare
v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_feld text;
begin
foreach v_feld in array array[
'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation',
'title_prefix', 'title_suffix'
] loop
if v_lauf not like '%' || v_feld || '%' then
raise exception 'Der Nachtlauf kennt % nicht — eine datierte Aenderung daran verfiele.', v_feld;
end if;
end loop;
-- Und die frueher schon einmal verlorenen bleiben drin.
if v_lauf not like '%target_position_id%' then
raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht mehr.';
end if;
if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then
raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um.';
end if;
end
$$;

View File

@@ -0,0 +1,141 @@
-- Die Rechteprüfung in den Policies einmal je Abfrage statt einmal je Zeile.
--
-- ═══ Der Befund ════════════════════════════════════════════════════
--
-- Die Personalakte brauchte beim Öffnen rund anderthalb Sekunden. Gemessen
-- im Container, nicht geschätzt:
--
-- [Akte 47313676] person 4ms, hauptabfrage 1554ms, nachschlag 11ms
--
-- Die Hauptabfrage ist es also, und darin om_reporting_lines():
--
-- als postgres (RLS wird übergangen) 60 ms
-- als alpenwerk_app (RLS in Kraft) 1457 ms
--
-- Derselbe Aufschlag liegt auf allem anderen: ein blosses count(*) kostet
-- unter RLS 44 bis 55 ms statt 7.
--
-- ═══ Die Ursache ═══════════════════════════════════════════════════
--
-- Ein Policy-Ausdruck wird **je Zeile** ausgewertet. `is_hr_user()` ist zwar
-- `stable`, aber das erlaubt Postgres nur, den Wert innerhalb einer Anweisung
-- als unveränderlich anzusehen — nicht, ihn aus dem Zeilenfilter
-- herauszuziehen. Bei jeder Zeile laufen also zwei Unterabfragen auf
-- `profiles` und `app_passwoerter`.
--
-- In einen Unterausdruck gefasst wird daraus ein InitPlan, den der Planer
-- **einmal** auswertet. Gemessen, ohne RLS, damit nur dieser Unterschied
-- sichtbar ist:
--
-- select count(*) from om_positions where is_hr_user(); 14,8 ms
-- select count(*) from om_positions where (select is_hr_user()); 0,5 ms
-- select count(*) from employees where is_hr_user(); 4,1 ms
-- select count(*) from employees where (select is_hr_user()); 0,5 ms
--
-- ═══ Was sich dabei **nicht** ändert ═══════════════════════════════
--
-- Die Zugriffsentscheidung. Es ist dieselbe Funktion mit demselben Ergebnis;
-- sie wird nur nicht mehr achthundertmal gefragt, sondern einmal. Keine
-- Tabelle verliert ihre Policy, keine Rolle bekommt Rechte dazu, und
-- `alpenwerk_app` bleibt ohne BYPASSRLS.
--
-- Der naheliegende andere Weg — om_reporting_lines auf `security definer`
-- umstellen, damit sie die Tabellen am Zeilenschutz vorbei liest — wäre
-- genau das nicht: er verschöbe die Grenze, um schneller zu werden, und
-- hälfe nur dieser einen Funktion. Der Aufschlag liegt aber auf jeder
-- Abfrage der Anwendung.
--
-- ═══ Warum erzeugt und nicht abgeschrieben ═════════════════════════
--
-- Es sind rund zwanzig Policies. Eine davon von Hand falsch abzuschreiben
-- hiesse, eine Tabelle zu öffnen oder zu schliessen, ohne dass es auffällt.
-- Deshalb werden die bestehenden Definitionen aus `pg_policies` gelesen und
-- unverändert wieder angelegt — ersetzt wird allein der Funktionsaufruf.
-- Erst sammeln, dann umbauen: ein Cursor über pg_policies, der während des
-- Laufs Policies austauscht, liest aus einem Katalog, den er selbst ändert.
create temp table policy_umbau on commit drop as
select tablename, policyname, permissive, cmd,
array_to_string(roles, ', ') as rollen, qual, with_check
from pg_policies
where schemaname = 'public'
and (coalesce(qual, '') || coalesce(with_check, '')) like '%is_hr_user()%'
and (coalesce(qual, '') || coalesce(with_check, '')) not like '%SELECT is_hr_user()%';
do $$
declare
r record;
v_qual text;
v_check text;
v_sql text;
v_anzahl int := 0;
begin
for r in select * from policy_umbau order by tablename, policyname loop
-- Nur der Aufruf wird gefasst. app_current_user_id() steht in den
-- Policies der Entwürfe und Berichte neben is_hr_user() und wird aus
-- demselben Grund je Zeile gerufen.
v_qual := replace(replace(r.qual, 'is_hr_user()', '(select is_hr_user())'),
'app_current_user_id()', '(select app_current_user_id())');
v_check := replace(replace(r.with_check, 'is_hr_user()', '(select is_hr_user())'),
'app_current_user_id()', '(select app_current_user_id())');
execute format('drop policy %I on public.%I', r.policyname, r.tablename);
v_sql := format('create policy %I on public.%I as %s for %s to %s',
r.policyname, r.tablename, r.permissive, r.cmd, r.rollen);
-- Eine INSERT-Policy hat kein `using`, eine SELECT- und DELETE-Policy
-- kein `with check`. Beides anzugeben wäre ein Syntaxfehler.
if v_qual is not null then v_sql := v_sql || format(' using (%s)', v_qual); end if;
if v_check is not null then v_sql := v_sql || format(' with check (%s)', v_check); end if;
execute v_sql;
v_anzahl := v_anzahl + 1;
end loop;
raise notice 'Policies umgeschrieben: %', v_anzahl;
end
$$;
-- Selbstprüfung.
do $$
declare
v_offen int;
v_vorher int;
v_jetzt int;
begin
-- Keine Policy darf den Aufruf noch ungefasst führen.
select count(*) into v_offen
from pg_policies
where schemaname = 'public'
and (coalesce(qual, '') || coalesce(with_check, '')) like '%is_hr_user()%'
and (coalesce(qual, '') || coalesce(with_check, '')) not like '%SELECT is_hr_user()%';
if v_offen > 0 then
raise exception '% Policies pruefen die Rechte weiterhin je Zeile.', v_offen;
end if;
-- Und keine darf dabei verlorengegangen sein.
select count(*) into v_vorher from policy_umbau;
select count(*) into v_jetzt
from pg_policies
where schemaname = 'public'
and (coalesce(qual, '') || coalesce(with_check, '')) like '%SELECT is_hr_user()%';
if v_jetzt < v_vorher then
raise exception 'Vorher % Policies mit Rechtepruefung, jetzt nur noch % — es fehlt eine.', v_vorher, v_jetzt;
end if;
-- Jede Tabelle, die vorher geschützt war, ist es weiterhin: Zeilenschutz
-- eingeschaltet und mindestens eine Policy darauf.
if exists (
select 1 from policy_umbau u
where not exists (
select 1 from pg_policies p
where p.schemaname = 'public' and p.tablename = u.tablename
)
) then
raise exception 'Eine Tabelle hat ihre Policies verloren.';
end if;
raise notice 'Geprueft: % Policies umgestellt, keine offen.', v_vorher;
end
$$;

View File

@@ -0,0 +1,172 @@
-- om_reporting_lines prüft die Rechte einmal, statt sie je Zeile prüfen zu lassen.
--
-- ═══ Gemessen, nicht geschätzt ═════════════════════════════════════
--
-- Die Personalakte brauchte beim Öffnen 1,3 bis 1,5 Sekunden; davon lagen
-- über 1,1 s in dieser Funktion. Als `postgres` — also am Zeilenschutz vorbei
-- — kostet dieselbe Funktion 60 ms.
--
-- als postgres 60 ms
-- als alpenwerk_app, vor 20260924200000 1457 ms
-- als alpenwerk_app, danach 1153 ms
--
-- Die vorige Migration hat die Policies so gefasst, dass `is_hr_user()` je
-- Abfrage einmal läuft statt je Zeile. Das half, aber nur um ein Fünftel —
-- also war die Prüfung nicht der Hauptposten.
--
-- Die Teile der Funktion sind einzeln billig, unter Zeilenschutz gemessen:
--
-- laufende Besetzungen (784 Zeilen) 12 ms
-- Vorfahrenkette (379 Zeilen) 7 ms
-- Leitungen je Einheit (74 Zeilen) 9 ms
--
-- Zusammen 28 ms — die Funktion daraus 1153. Die Kosten entstehen also nicht
-- beim Lesen, sondern beim Zusammensetzen: unter Zeilenschutz wird aus jeder
-- Tabellenreferenz eine Unterabfrage mit Sicherheitsschranke, und der Planer
-- verliert die Freiheit, die Zwischenmengen **einmal** zu berechnen. Die
-- beiden Unterabfragen am Ende laufen dann für jede der 784 Zeilen erneut.
--
-- ═══ Was sich ändert und was nicht ═════════════════════════════════
--
-- Die Funktion läuft jetzt als `security definer` und beginnt mit
-- `require_hr_admin()`. Das ist **dieselbe Bedingung**, die in den Policies
-- aller vier gelesenen Tabellen steht — `require_hr_admin()` ist wörtlich
-- „if not is_hr_user() then raise". Sie wird nur einmal gestellt statt
-- hunderttausendfach.
--
-- Wer Daten bekommt, ändert sich nicht: aktive HR-Person ja, alle anderen
-- nein. Der Unterschied ist, dass eine nicht berechtigte Anfrage jetzt eine
-- Meldung bekommt statt einer leeren Antwort — so verhält sich jede andere
-- Funktion dieser Anwendung auch.
--
-- ═══ Der Preis, der dafür anfällt ══════════════════════════════════
--
-- Diese Funktion liest die vier Tabellen künftig am Zeilenschutz vorbei. Käme
-- je eine feinere Rechteordnung — Unterrollen, HR-Personen mit
-- eingeschränktem Blick —, dann müsste sie **eigens** nachgezogen werden; die
-- Policies tun es für sie nicht mehr. Mit dem Eigentümer besprochen und
-- entschieden: solche Rollen sind nicht vorgesehen.
--
-- Der Körper ist unverändert aus 20260727120100 übernommen — die einzige
-- textliche Fassung, die es je gab. Geändert sind nur Sprache (für die
-- Prüfung braucht es eine Anweisung vor der Abfrage), `security definer`,
-- der feste `search_path` und eben diese erste Zeile.
create or replace function om_reporting_lines(p_as_of date default current_date)
returns table (
employee_id uuid,
position_id uuid,
org_unit_id uuid,
is_chief boolean,
formal_manager_id uuid,
acting_manager_id uuid
)
language plpgsql
stable
security definer
set search_path to 'public', 'pg_temp'
as $$
begin
perform require_hr_admin();
return query
with recursive
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
-- gültig sein.
holder as (
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
from position_assignments pa
join om_positions p on p.id = pa.position_id
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
),
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
chief as (
select h.org_unit_id, h.employee_id,
(e.karenz_start_date is not null
and e.karenz_start_date <= p_as_of
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
from holder h
join employees e on e.id = h.employee_id
where h.is_chief
),
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst.
ancestry as (
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
from org_units u
union all
select a.unit_id, p.id, p.parent_id, a.depth + 1
from ancestry a
join org_units p on p.id = a.parent_id
),
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
-- sonst die eigene.
base as (
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
from holder h
join org_units u on u.id = h.org_unit_id
)
select
b.employee_id,
b.position_id,
b.org_unit_id,
b.is_chief,
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
-- die Ausgangseinheit eingeschlossen.
(
select c.employee_id
from ancestry a
join chief c on c.org_unit_id = a.ancestor_id
where a.unit_id = b.base_unit_id
and not c.absent
and c.employee_id <> b.employee_id
order by a.depth
limit 1
) as acting_manager_id
from base b;
end;
$$;
comment on function om_reporting_lines(date) is
'Abgeleitete Berichtslinie zum Stichtag. Security definer mit require_hr_admin() in der ersten Zeile: dieselbe Bedingung wie in den Policies der gelesenen Tabellen, einmal geprüft statt je Zeile.';
grant execute on function public.om_reporting_lines(p_as_of date) to alpenwerk_app;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.om_reporting_lines(date)'::regprocedure);
v_teil text;
begin
-- Ohne die Prüfung in der ersten Zeile wäre aus der Funktion ein Loch im
-- Zeilenschutz geworden: security definer liest an den Policies vorbei.
if v_def not like '%require_hr_admin%' then
raise exception 'om_reporting_lines prueft die Rechte nicht — security definer waere hier ein Loch.';
end if;
if position('SECURITY DEFINER' in upper(v_def)) = 0 then
raise exception 'om_reporting_lines ist nicht security definer — die Umstellung hat nicht gegriffen.';
end if;
if position('SET search_path' in v_def) = 0 then
raise exception 'om_reporting_lines hat keinen festen search_path — bei security definer ist das Pflicht.';
end if;
-- Der Körper wird **gelesen, nicht ausgeführt**. Ein Aufruf hier wäre der
-- erste Fehlversuch dieser Migration gewesen: sie läuft als Administrator
-- ohne `app.user_id`, und require_hr_admin() weist sie damit zu Recht ab.
-- Eine Selbstprüfung darf nichts aufrufen, was einen angemeldeten
-- Anwendungsbenutzer voraussetzt.
foreach v_teil in array array[
'holder as', 'chief as', 'ancestry as', 'base as',
'formal_manager_id', 'acting_manager_id', 'order by a.depth'
] loop
if position(v_teil in v_def) = 0 then
raise exception 'Im Koerper fehlt „%" — beim Uebertragen ist etwas verlorengegangen.', v_teil;
end if;
end loop;
end
$$;

View File

@@ -0,0 +1,866 @@
-- Die Cornerstone-ID als eigenes, freiwilliges Feld.
--
-- Cornerstone (CSOD) führt jede Person unter einer eigenen Kennung. Der
-- Export dorthin (lib/cornerstone.ts) trug in „User ID" und „Username"
-- bisher die Alpenwerk-UUID — richtig, solange es nichts Besseres gab, aber
-- eben nicht die Kennung, unter der Cornerstone die Person kennt. Ab jetzt
-- steht dort diese Spalte.
--
-- Freiwillig: für 434 der 784 Personen liefert die Zuordnungstabelle des
-- Kunden kein „#N/A", also keine Kennung. Ein Pflichtfeld hätte jede
-- Änderung an diesen Zeilen blockiert.
--
-- Eindeutig: eine Kennung gehört genau einer Person. Mehrere ohne Kennung
-- stören den Index nicht, weil null nie gleich null ist.
--
-- Text und keine Zahl: die Kennungen sind achtstellig, zwei sind
-- siebenstellig, und führende Nullen wären in einer Zahlenspalte verloren.
-- Gerechnet wird damit ohnehin nicht.
--
-- Fünf Funktionen müssen mit, weil `create or replace` die ganze Fassung
-- ersetzt und ein ausgelassenes Feld dort still verschwindet — dieselbe
-- Liste und derselbe Grund wie bei der Firmen-E-Mail (20260923120000):
-- hire_employee und rehire_employee teilen sich den Schritt „Person",
-- change_employee_data macht das Feld änderbar, apply_due_pending_changes
-- sorgt dafür, dass eine auf später datierte Änderung nicht verfällt, und
-- app_feld_karte, damit der Eintrag in der Historie richtigstellbar bleibt.
alter table employees add column if not exists cornerstone_id text;
comment on column employees.cornerstone_id is
'Kennung der Person im Lernsystem Cornerstone (CSOD). Freiwillig, eindeutig. Füllt User ID und Username im Cornerstone-Export.';
create unique index if not exists employees_cornerstone_id_key on employees (cornerstone_id);
-- ── hire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
RETURNS uuid
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false);
v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false);
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
raise exception 'Es muss eine Personalnummer angegeben werden.';
end if;
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
end if;
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_entry < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
end if;
if v_bis is not null and v_entry >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_entry);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, company_email, cornerstone_id, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, mitarbeiterart, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis,
ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis,
hat_aufenthaltstitel, aufenthaltstitel_bis,
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
)
values (
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'company_email', payload->>'cornerstone_id', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce(nullif(payload->>'mitarbeiterart', ''), 'Standard'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false),
v_schutz,
-- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist:
-- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen
-- den ganzen Vorgang zurück.
case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end,
case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
v_behindert,
case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end,
case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end,
case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end,
coalesce((payload->>'hat_aufenthaltstitel')::boolean, false),
case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false)
then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end,
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
nullif(payload->>'emergency_contact_name', ''),
nullif(payload->>'emergency_contact_phone', ''),
nullif(payload->>'emergency_contact_relation', '')
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$function$;
-- ── rehire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.rehire_employee(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
v_alt employees%rowtype;
v_besetzt uuid;
v_schutz boolean;
v_behindert boolean;
begin
perform require_hr_admin();
select * into v_alt from employees where id = v_employee_id;
if v_alt.id is null then
raise exception 'Die Person existiert nicht.';
end if;
v_name := v_alt.first_name || ' ' || v_alt.last_name;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
-- Dieselben Prüfungen wie bei einer Neueinstellung. Sie fehlten hier, und
-- das hiess: eine Wiedereinstellung auf eine besetzte oder noch nicht
-- gültige Planstelle scheiterte erst an einem Index oder gar nicht.
declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
if v_ab is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if v_date < v_ab then
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_date;
end if;
if v_bis is not null and v_date >= v_bis then
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_date;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id
and (pa.valid_to is null or pa.valid_to > v_date);
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
-- Die beiden Kennzeichen zuerst: an ihnen hängen Felder, die ohne sie nicht
-- dastehen dürfen (chk_kuendigungsschutz_grund, chk_behinderung).
v_schutz := coalesce((payload->>'has_kuendigungsschutz')::boolean, v_alt.has_kuendigungsschutz);
v_behindert := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, v_alt.ist_beguenstigt_behindert);
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null,
austrittsart = null,
first_name = coalesce(payload->>'first_name', first_name),
last_name = coalesce(payload->>'last_name', last_name),
gender = coalesce((payload->>'gender')::gender_type, gender),
birth_date = coalesce((payload->>'birth_date')::date, birth_date),
sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(payload->>'nationality', nationality),
email = case when payload ? 'email' then nullif(payload->>'email', '') else email end,
company_email = case when payload ? 'company_email' then nullif(payload->>'company_email', '') else company_email end,
cornerstone_id = case when payload ? 'cornerstone_id' then nullif(payload->>'cornerstone_id', '') else cornerstone_id end,
phone = case when payload ? 'phone' then nullif(payload->>'phone', '') else phone end,
address = coalesce(payload->>'address', address),
postal_code = coalesce(payload->>'postal_code', postal_code),
city = coalesce(payload->>'city', city),
address_country = coalesce(payload->>'address_country', address_country),
location_id = coalesce((payload->>'location_id')::uuid, location_id),
title_prefix = case when payload ? 'title_prefix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}')
else title_prefix end,
title_suffix = case when payload ? 'title_suffix'
then coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}')
else title_suffix end,
emergency_contact_name = case when payload ? 'emergency_contact_name' then nullif(payload->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when payload ? 'emergency_contact_phone' then nullif(payload->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when payload ? 'emergency_contact_relation' then nullif(payload->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel)
then case when payload ? 'aufenthaltstitel_bis' then nullif(payload->>'aufenthaltstitel_bis', '')::date else aufenthaltstitel_bis end
else null
end,
-- Die Tätigkeit kommt von der Planstelle, nicht aus dem payload: sie
-- steht am Platz, den die Person einnimmt, und nicht an ihr.
job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
employment_type = coalesce((payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((payload->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((payload->>'contract_type')::contract_type, contract_type),
contract_end_date = case when payload ? 'contract_end_date' then nullif(payload->>'contract_end_date', '')::date else contract_end_date end,
paygrade = coalesce((payload->>'paygrade')::paygrade_type, paygrade),
source = coalesce((payload->>'source')::source_type, source),
worker_type = coalesce((payload->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(payload->>'mitarbeiterart', ''), mitarbeiterart),
collective_agreement = coalesce((payload->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when payload ? 'work_days'
then coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), work_days)
else work_days end,
is_betriebsrat = coalesce((payload->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen),
dienstwagen_art = case
when coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen)
then coalesce(nullif(payload->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
is_laterale_fuehrung = coalesce((payload->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((payload->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = v_schutz,
kuendigungsschutz_grund = case when v_schutz then
case when payload ? 'kuendigungsschutz_grund' then nullif(payload->>'kuendigungsschutz_grund', '') else kuendigungsschutz_grund end
else null end,
kuendigungsschutz_ab = case when v_schutz then
case when payload ? 'kuendigungsschutz_ab' then nullif(payload->>'kuendigungsschutz_ab', '')::date else kuendigungsschutz_ab end
else null end,
kuendigungsschutz_bis = case when v_schutz then
case when payload ? 'kuendigungsschutz_bis' then nullif(payload->>'kuendigungsschutz_bis', '')::date else kuendigungsschutz_bis end
else null end,
ist_beguenstigt_behindert = v_behindert,
behinderung_grad = case when v_behindert then
case when payload ? 'behinderung_grad' then nullif(payload->>'behinderung_grad', '')::integer else behinderung_grad end
else null end,
behinderung_ab = case when v_behindert then
case when payload ? 'behinderung_ab' then nullif(payload->>'behinderung_ab', '')::date else behinderung_ab end
else null end,
behinderung_bis = case when v_behindert then
case when payload ? 'behinderung_bis' then nullif(payload->>'behinderung_bis', '')::date else behinderung_bis end
else null end
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt',
'Wiedereinstellung zum ' || v_date || ' auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id,
'Wiedereintritt am ' || v_date);
end;
$function$;
-- ── app_feld_karte (Vorlage: 20260923120000_firmenemail.sql) ──────────
CREATE OR REPLACE FUNCTION public.app_feld_karte()
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path TO 'public', 'pg_temp'
AS $function$
select jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
'Adresse', jsonb_build_array('address', 'text', 'person'),
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
'Ort', jsonb_build_array('city', 'text', 'person'),
'Land', jsonb_build_array('address_country', 'text', 'person'),
'E-Mail', jsonb_build_array('email', 'text', 'person'),
'Firmen-E-Mail', jsonb_build_array('company_email', 'text', 'person'),
'Cornerstone-ID', jsonb_build_array('cornerstone_id', 'text', 'person'),
'Telefon', jsonb_build_array('phone', 'text', 'person'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'),
'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', 'person'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
'Mitarbeiterart', jsonb_build_array('mitarbeiterart', 'text', 'role'),
'Besetzungsart', jsonb_build_array('source', 'source_type', 'role'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
'Kündigungsschutz Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'),
'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'),
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'),
-- 'integer' und nicht 'numeric': der Typ geht in update_history_entry
-- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über
-- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete
-- dabei — der Grad ist eine ganze Zahl aus einem Bescheid.
'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'),
'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'),
'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
-- und Rückkehr ihre eigenen Vorgänge haben.
'Status', jsonb_build_array('status', 'employment_status', null),
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
);
$function$;
-- ── change_employee_data (Vorlage: 20260923160000_leere_angaben_sind_null.sql) ──────────
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
v_pending_id uuid;
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'company_email' then v_person_changes := app_aenderung(v_person_changes, 'Firmen-E-Mail', v_old.company_email, v_person->>'company_email'); end if;
if v_person ? 'cornerstone_id' then v_person_changes := app_aenderung(v_person_changes, 'Cornerstone-ID', v_old.cornerstone_id, v_person->>'cornerstone_id'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
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_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'mitarbeiterart' then v_contract_changes := app_aenderung(v_contract_changes, 'Mitarbeiterart', v_old.mitarbeiterart, v_role->>'mitarbeiterart'); end if;
if v_role ? 'source' then v_contract_changes := app_aenderung(v_contract_changes, 'Besetzungsart', v_old.source::text, v_role->>'source'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = case when v_person ? 'sv_nummer' then nullif(v_person->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_person->>'nationality', nationality),
address = case when v_person ? 'address' then nullif(v_person->>'address', '') else address end,
postal_code = case when v_person ? 'postal_code' then nullif(v_person->>'postal_code', '') else postal_code end,
city = case when v_person ? 'city' then nullif(v_person->>'city', '') else city end,
address_country = case when v_person ? 'address_country' then nullif(v_person->>'address_country', '') else address_country end,
email = case when v_person ? 'email' then nullif(v_person->>'email', '') else email end,
phone = case when v_person ? 'phone' then nullif(v_person->>'phone', '') else phone end,
company_email = case when v_person ? 'company_email' then nullif(v_person->>'company_email', '') else company_email end,
cornerstone_id = case when v_person ? 'cornerstone_id' then nullif(v_person->>'cornerstone_id', '') else cornerstone_id end,
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
aufenthaltstitel_bis = case
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_person ? 'aufenthaltstitel_bis'
then nullif(v_person->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
-- Ohne nullif: die Spalte ist NOT NULL, ein leerer Text waere kein
-- geloeschter Wert, sondern ein Wert, den chk_mitarbeiterart abweist.
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund'
then nullif(v_role->>'kuendigungsschutz_grund','')
else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab'
then nullif(v_role->>'kuendigungsschutz_ab','')::date
else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis'
then nullif(v_role->>'kuendigungsschutz_bis','')::date
else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end,
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload)
returning id into v_pending_id;
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
-- ── Die Übernahme ────────────────────────────────────────────────
--
-- Nur in dieser einen Richtung. Wer von extern nach intern wechselt, wird
-- übernommen; der umgekehrte Weg ist kein Vorgang, den es fachlich gibt.
--
-- Das Ereignis entsteht **zusätzlich** zur Vertragsänderung darüber und
-- nicht an ihrer Stelle: die eine hält fest, dass ein Feld sich geändert
-- hat und worauf (und lässt sich darüber zurücknehmen), das andere, dass
-- dieser Wechsel eine Übernahme war — und nur das lässt sich zählen.
--
-- `v_effective_date`, nicht `now()`: eine auf später datierte Übernahme
-- gehört in die Akte auf den Tag, an dem sie gilt. Bei einer sofortigen
-- ist das heute.
if v_role ? 'source' and v_old.source::text = 'Extern' and v_role->>'source' = 'Intern' then
insert into employee_history (employee_id, event_date, event_type, description, pending_id)
values (v_employee_id, v_effective_date, 'Übernahme',
'Übernahme von extern auf intern, wirksam ab ' || v_effective_date, v_pending_id);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Übernahme', v_name, v_employee_id,
'Von extern auf intern, wirksam ab ' || v_effective_date);
end if;
end;
$function$;
-- ── apply_due_pending_changes (Vorlage: 20260924180000_datierte_aenderung_vollstaendig.sql) ──────────
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_rec record;
v_count integer := 0;
v_role jsonb;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
-- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen
-- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt
-- — die Fassung von vor der Umstellung auf Planstellen. Eine auf
-- später datierte Versetzung galt damit am Stichtag als angewendet
-- und bewegte niemanden.
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
-- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig
-- stünde am Stichtag der neue Titel an der Person und sie säsze
-- weiterhin auf der alten Stelle — im Organigramm unverändert.
if nullif(v_rec.payload->>'target_position_id', '') is not null then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
end if;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
-- wäre verschwunden.
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
v_role := v_rec.payload->'role';
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
cornerstone_id = case when v_rec.payload->'person' ? 'cornerstone_id' then nullif(v_rec.payload->'person'->>'cornerstone_id', '') else cornerstone_id end,
-- Der Notfallkontakt und die Titel fehlten hier. Eine auf später
-- datierte Änderung stand damit in der Akte, kam aber nie an der
-- Person an: change_employee_data schreibt sie sofort, dieser Zweig
-- kannte sie nicht. Dieselbe Lücke wie einst bei der Gruppe `role`
-- (20260915120000).
emergency_contact_name = case when v_rec.payload->'person' ? 'emergency_contact_name'
then nullif(v_rec.payload->'person'->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_rec.payload->'person' ? 'emergency_contact_phone'
then nullif(v_rec.payload->'person'->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_rec.payload->'person' ? 'emergency_contact_relation'
then nullif(v_rec.payload->'person'->>'emergency_contact_relation', '') else emergency_contact_relation end,
-- Wie work_days weiter unten: eine leere Liste ist eine Angabe, kein
-- fehlender Wert, deshalb coalesce auf '{}' statt auf den alten Stand.
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}')
else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}')
else title_suffix end,
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end,
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
teilzeit_bis = case
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
else teilzeit_bis
end,
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
aufenthaltstitel_bis = case
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
else aufenthaltstitel_bis end
else null
end,
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
kuendigungsschutz_grund = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
else null
end,
kuendigungsschutz_ab = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
else null
end,
kuendigungsschutz_bis = case
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
else null
end,
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
behinderung_grad = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
else null
end,
behinderung_ab = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
else null
end,
behinderung_bis = case
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
else null
end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::text,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_fn text;
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employees' and column_name = 'cornerstone_id'
) then
raise exception 'Die Spalte cornerstone_id fehlt.';
end if;
-- Jede der fünf einzeln: fehlt sie in einer, verschwindet das Feld genau
-- in deren Weg, ohne dass es auffällt.
foreach v_fn in array array[
'public.hire_employee(jsonb)',
'public.rehire_employee(jsonb)',
'public.change_employee_data(jsonb)',
'public.apply_due_pending_changes()'
] loop
if pg_get_functiondef(v_fn::regprocedure) not like '%cornerstone_id%' then
raise exception '% kennt die Cornerstone-ID nicht.', v_fn;
end if;
end loop;
if app_feld_karte() -> 'Cornerstone-ID' is null then
raise exception 'Die Feldkarte kennt die Cornerstone-ID nicht — der Eintrag in der Historie waere nicht korrigierbar.';
end if;
-- Und was zuletzt hier verlorenging, bleibt drin.
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%emergency_contact_name%' then
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
end if;
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%target_position_id%' then
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
end if;
end
$$;

View File

@@ -0,0 +1,247 @@
-- Hay-Grade statt Verwendungsgruppe A–F
--
-- ═══ 1. Warum die Liste wechselt ═══════════════════════════════════
--
-- A–F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene
-- Stufen mit erfundenen Beschreibungen („B – Qualifiziert"). Der Kunde
-- bewertet nach Hay und hat die Liste geschickt — 13 Stufen plus einen
-- „Generic Grade" für alles, was keine trägt.
--
-- Die Spalte füllt im Cornerstone-Extrakt die „Grade ID". Solange dort A–F
-- steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
-- auffällt: das Zielsystem kennt diese Kennungen nicht.
--
-- ═══ 2. Warum eine Domain und kein Aufzählungstyp ══════════════════
--
-- `paygrade_type` war ein Enum. Ein Enum lässt sich nicht umschreiben:
-- Werte entfernen geht gar nicht, und `alter type … add value` darf im
-- selben Vorgang, der den neuen Wert schreibt, nicht benutzt werden — die
-- Umstellung bräuchte also zwei Migrationen und liesse dazwischen eine
-- Datenbank mit beiden Listen zurück.
--
-- Der Typ **heisst** deshalb weiter `paygrade_type`, ist aber jetzt eine
-- Domain über text mit CHECK. Das ist die Form, die dieses Projekt für
-- kundenseitige Listen ohnehin schon führt (Abwesenheitsart, Personenkreis,
-- Mitarbeiterart) — und, wichtiger: die rund zehn SQL-Funktionen, die
-- `(payload->>'paygrade')::paygrade_type` schreiben, bleiben unverändert
-- gültig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
-- haben, aus einer veralteten Vorlage zu kopieren; genau daran hat dieses
-- Projekt schon dreimal Verhalten verloren.
--
-- Eine einzige Stelle muss doch angefasst werden: `hire_employee` setzt als
-- Vorgabe das Literal 'B', und das besteht den CHECK nicht mehr. Sie wird
-- unten **nicht** aus einer Datei kopiert, sondern aus der laufenden
-- Definition gelesen und an genau dieser Stelle geändert.
-- ═══ 3. Hängt der Typ noch woanders? ══════════════════════════════
--
-- Die Dateien sagen: nur employees.paygrade. Die Datenbank ist die Quelle,
-- nicht die Dateien — also gefragt statt angenommen. Hinge eine zweite
-- Spalte daran, scheiterte der DROP weiter unten ohnehin, aber mit einer
-- Meldung, die nicht sagt, was los ist.
do $$
declare
v_spalten text;
begin
select string_agg(c.relname || '.' || a.attname, ', ')
into v_spalten
from pg_attribute a
join pg_class c on c.oid = a.attrelid
join pg_namespace n on n.oid = c.relnamespace
where a.atttypid = 'paygrade_type'::regtype
and a.attnum > 0
and not a.attisdropped
and n.nspname = 'public'
and not (c.relname = 'employees' and a.attname = 'paygrade');
if v_spalten is not null then
raise exception 'paygrade_type wird noch benutzt von: %. Umstellung abgebrochen.', v_spalten;
end if;
end
$$;
-- ═══ 4. Offene datierte Beförderungen ═════════════════════════════
--
-- Eine Beförderung mit Datum in der Zukunft liegt als pending-Zeile mit
-- `new_paygrade` im Nutzdatensatz. Steht dort A–F, scheiterte der Nachtlauf
-- Wochen später an der Umwandlung — um drei Uhr früh und ohne jemanden, dem
-- die Meldung zugestellt würde. Lieber jetzt und laut.
do $$
declare
v_anzahl int;
begin
select count(*) into v_anzahl
from pending_org_changes
where status = 'pending'
and payload ? 'new_paygrade'
and payload->>'new_paygrade' not in
('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
if v_anzahl > 0 then
raise exception
'Es warten % geplante Beförderungen auf eine Verwendungsgruppe, die es nicht mehr gibt. Erst entscheiden, dann umstellen.',
v_anzahl;
end if;
end
$$;
-- ═══ 5. Der Typwechsel ════════════════════════════════════════════
alter table employees alter column paygrade drop default;
alter table employees alter column paygrade type text using paygrade::text;
drop type paygrade_type;
create domain paygrade_type as text
constraint chk_hay_grade check (
value in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20')
);
-- Der Bestand bekommt „Generic Grade". Aus A–F liesse sich kein Hay-Grade
-- ableiten: andere Einteilung, andere Anzahl. Geraten sähe im Extrakt
-- genauso aus wie erhoben — und wäre nicht mehr davon zu unterscheiden.
update employees
set paygrade = '-'
where paygrade not in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
alter table employees alter column paygrade type paygrade_type using paygrade::paygrade_type;
alter table employees alter column paygrade set default '-';
-- ═══ 6. hire_employee: Vorgabewert 'B' → '-' ══════════════════════
--
-- Aus der laufenden Definition gelesen, nicht aus einer Migrationsdatei
-- kopiert: ein Teil der Funktionen dieses Projekts ist nachträglich
-- dynamisch gepatcht worden, die Summe der Dateien ist also nicht das, was
-- in der Datenbank steht. Der Anker muss genau einmal vorkommen — käme er
-- keinmal vor, hätte jemand die Vorgabe schon geändert und wir überschrieben
-- etwas Unbekanntes; käme er zweimal vor, träfe die Ersetzung eine Stelle,
-- die hier niemand angeschaut hat.
do $migration$
declare
v_alt constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, 'B')$anker$;
v_neu constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, '-')$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 = 'hire_employee'
and p.prokind = 'f';
if v_def is null then
raise exception 'hire_employee 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 Vorgabewert kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 6b. promote_employee: die Beschriftung im Protokoll ══════════
--
-- Die Funktion schreibt den Grund einer Beförderung als Klartext nach
-- audit_log und employee_history: „…, neue Verwendungsgruppe: C". Diese
-- Zeilen liest der Kunde. Bliebe das Wort stehen, hiesse ein Feld in der
-- Maske anders als im Protokoll darüber — dieselbe Änderung unter zwei
-- Namen. Bestehende Einträge bleiben, wie sie sind: Historie wird
-- fortgeschrieben, nicht umgeschrieben.
do $migration$
declare
v_alt constant text := $anker$, neue Verwendungsgruppe: $anker$;
v_neu constant text := $anker$, neuer Hay-Grade: $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 = 'promote_employee'
and p.prokind = 'f';
if v_def is null then
raise exception 'promote_employee 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 'Die Beschriftung kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 7. Selbstprüfung ═════════════════════════════════════════════
--
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
-- Neuerzeugungen schon einmal still verschwunden ist: Rechteprüfung,
-- search_path und die zuletzt hinzugekommenen Felder. `hire_employee` ist
-- gerade neu erzeugt worden — wenn dabei etwas abhanden gekommen wäre, wäre
-- es hier zu sehen und nirgends sonst.
do $$
declare
v_def text;
v_werte int;
begin
select count(*) into v_werte
from pg_constraint
where conname = 'chk_hay_grade'
and contypid = 'paygrade_type'::regtype;
if v_werte <> 1 then
raise exception 'Die Bedingung chk_hay_grade fehlt an paygrade_type.';
end if;
if exists (select 1 from employees where paygrade::text = 'B') then
raise exception 'Es stehen noch Verwendungsgruppen in employees.paygrade.';
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 = 'hire_employee' and p.prokind = 'f';
if v_def not like '%paygrade_type, ''-''%' then
raise exception 'hire_employee traegt den neuen Vorgabewert nicht.';
end if;
if v_def like '%paygrade_type, ''B''%' then
raise exception 'hire_employee traegt noch den alten Vorgabewert.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'hire_employee hat die Rechtepruefung verloren.';
end if;
if v_def not like '%search_path%' then
raise exception 'hire_employee hat den search_path verloren.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'hire_employee hat die Cornerstone-ID verloren.';
end if;
if v_def not like '%company_email%' then
raise exception 'hire_employee hat die Firmen-E-Mail 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 = 'promote_employee' and p.prokind = 'f';
if v_def not like '%neuer Hay-Grade%' then
raise exception 'promote_employee traegt die neue Beschriftung nicht.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee hat die Rechtepruefung verloren.';
end if;
-- Die Zielplanstelle kam erst mit 20260924100000 dazu und ist genau die Art
-- Verhalten, die bei einer Neuerzeugung schon einmal still verschwunden ist.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
raise notice 'Hay-Grade steht, hire_employee und promote_employee vollstaendig.';
end
$$;

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

View File

@@ -0,0 +1,156 @@
-- Eine Organisationseinheit aus der Anwendung heraus anlegen
--
-- Bisher gab es dafür nur den Import — und für alles andere an einer Einheit
-- (umbenennen, verschieben, schliessen) gibt es weiterhin nur die Datenbank.
-- Das hier ist bewusst nur der eine Fall: **eine neue Einheit unter einer
-- bestehenden**.
--
-- Warum gerade der: er ist der einzige, der nichts umschreibt. Die neue
-- Einheit ist immer ein Blatt, also
--
-- * kann kein Kreis entstehen — und das ist keine Kleinigkeit: die
-- rekursive Abfrage `ancestry` in om_reporting_lines hat weder
-- Tiefenbegrenzung noch Kreiserkennung, ein Kreis in parent_id würde dort
-- nicht zu einem falschen Ergebnis führen, sondern zu keinem;
-- * wird keine Vergangenheit verändert. org_units.parent_id trägt kein
-- Datum: wer eine bestehende Einheit umhängt, ändert damit auch jede
-- Auswertung auf einen vergangenen Stichtag, und der frühere Stand ist
-- danach nirgends mehr ablesbar. Beim Anlegen stellt sich die Frage
-- nicht, weil vorher nichts da war.
--
-- Das Verschieben bestehender Einheiten braucht deshalb eine Entscheidung
-- (Vergangenheit überschreiben oder die Aufhängung datieren) und steht
-- absichtlich noch nicht hier.
create or replace function create_org_unit(payload jsonb)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_parent_id uuid := (payload->>'parent_id')::uuid;
v_org_number text := nullif(trim(payload->>'org_number'), '');
v_name text := nullif(trim(payload->>'name'), '');
v_unit_type text := nullif(trim(payload->>'unit_type'), '');
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
v_leitung text := nullif(trim(payload->>'leitung_taetigkeit'), '');
v_parent record;
v_id uuid;
begin
perform require_hr_admin();
if v_parent_id is null then
-- Eine Einheit ohne Übergeordnete wäre eine zweite Wurzel. Es gibt genau
-- eine, und sie entsteht nicht hier.
raise exception 'Es muss eine übergeordnete Einheit angegeben werden.';
end if;
select id, org_number, name, valid_from, valid_to
into v_parent
from org_units
where id = v_parent_id;
if v_parent.id is null then
raise exception 'Die übergeordnete Einheit existiert nicht.';
end if;
if v_org_number is null then
raise exception 'Es muss eine Orgnummer angegeben werden.';
end if;
if v_name is null then
raise exception 'Es muss eine Bezeichnung angegeben werden.';
end if;
if v_unit_type is null then
raise exception 'Es muss eine Art angegeben werden.';
end if;
-- Die Gesellschaft ist das Ganze und steht über allem. Als Kind einer
-- anderen Einheit wäre sie ein Etikett, das seiner Stelle im Baum
-- widerspricht — und genau daran ist in diesem Projekt schon dreimal Code
-- zerbrochen, der aus dem Etikett auf die Ebene geschlossen hat.
if v_unit_type = 'Gesellschaft' then
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht unter einer anderen Einheit stehen.';
end if;
-- Der eindeutige Index würde das ebenfalls abfangen, aber mit einer
-- Meldung, die in der Oberfläche nichts erklärt.
if exists (select 1 from org_units where org_number = v_org_number) then
raise exception 'Die Orgnummer % ist bereits vergeben.', v_org_number;
end if;
-- Eine Einheit, die früher beginnt als die über ihr, hängt an einem Tag im
-- Baum, an dem es diesen Baum noch nicht gab. Auf einen solchen Stichtag
-- ausgewertet stünde sie ohne Vorfahren da.
if v_valid_from < v_parent.valid_from then
raise exception 'Die übergeordnete Einheit gilt erst ab %. Ein früherer Beginn ist nicht möglich.', v_parent.valid_from;
end if;
if v_parent.valid_to is not null and v_valid_from >= v_parent.valid_to then
raise exception 'Die übergeordnete Einheit gilt nur bis %.', v_parent.valid_to;
end if;
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
values (v_org_number, v_name, v_parent_id, v_unit_type::org_unit_type, v_valid_from)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit angelegt',
v_org_number || ' · ' || v_name,
'Unter ' || v_parent.org_number || ' · ' || v_parent.name ||
', Art ' || v_unit_type || ', gültig ab ' || v_valid_from ||
case when v_leitung is not null then ', mit Leitungsplanstelle „' || v_leitung || '“' else '' end);
-- Die Leitungsplanstelle entsteht über create_position und nicht durch eine
-- zweite Fassung derselben Logik. Dort hängen der Jobkatalog (gleiche
-- Tätigkeit = ein Eintrag), die Nummernvergabe und die Prüfung „je Einheit
-- genau eine Leitung“ — nachgebaut liefe das hier beim ersten Zusatz
-- auseinander. Sie schreibt ihren eigenen Protokolleintrag.
if v_leitung is not null then
perform create_position(jsonb_build_object(
'org_unit_id', v_id,
'job_title', v_leitung,
'is_chief', true,
'valid_from', v_valid_from
));
end if;
return v_id;
end;
$$;
comment on function create_org_unit(jsonb) is
'Legt eine Organisationseinheit unter einer bestehenden an, wahlweise gleich mit Leitungsplanstelle. Nur anlegen: umbenennen, verschieben und schliessen gibt es bewusst nicht, weil parent_id kein Datum trägt.';
grant execute on function create_org_unit(jsonb) to alpenwerk_app;
-- Selbstprüfung.
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 = 'create_org_unit' and p.prokind = 'f';
if v_def is null then
raise exception 'create_org_unit wurde nicht angelegt';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'create_org_unit prüft die Rechte nicht';
end if;
-- Ohne search_path liesse sich der Funktion über einen eigenen Schemapfad
-- eine andere Tabelle unterschieben. Hauskonvention für jede Funktion.
if v_def not like '%search_path%' then
raise exception 'create_org_unit hat keinen festen search_path';
end if;
if v_def not like '%audit_log%' then
raise exception 'create_org_unit schreibt nichts ins Protokoll';
end if;
-- Nicht nachgebaut, sondern aufgerufen — siehe den Kommentar dort.
if v_def not like '%create_position(%' then
raise exception 'create_org_unit legt die Leitungsplanstelle nicht über create_position an';
end if;
-- Ohne Übergeordnete entstünde eine zweite Wurzel.
if v_def not like '%parent_id%' then
raise exception 'create_org_unit kennt die übergeordnete Einheit nicht';
end if;
end
$$;

View File

@@ -0,0 +1,127 @@
-- Eine Organisationseinheit wieder entfernen
--
-- Das Gegenstück zu create_org_unit und mit derselben Einschränkung: es geht
-- nur dort, wo nichts umgeschrieben wird.
--
-- ═══ Warum gelöscht und nicht geschlossen ═════════════════════════
--
-- delete_position macht es seit dem OM-Umbau vor: besetzt → Abweisung, einmal
-- besetzt gewesen → `valid_to` statt Löschen (sonst verschwände mit der Stelle
-- die Besetzungshistorie), nie besetzt gewesen → weg. Der Unterschied ist
-- keine Vorsicht, sondern eine Aussage: was nie getragen hat, hat es auch nie
-- gegeben.
--
-- Für Einheiten lässt sich davon heute nur die letzte Stufe umsetzen. Ein
-- `valid_to` an einer Einheit wäre zwar eingetragen, aber **nirgends
-- gelesen**: weder das Organigramm noch orgMapsAbfragen, die Berichte, der
-- Druck oder die Auswahl beim Anlegen einer Planstelle schränken org_units auf
-- den Stichtag ein. Die geschlossene Einheit stünde überall weiter da, nur mit
-- einem Datum, das niemand sieht — das wäre schlechter als die klare
-- Abweisung unten, weil es wie Erledigt aussieht.
--
-- Deshalb hier nur: eine Einheit, unter der nie etwas hing, verschwindet
-- ganz. Alles andere wird abgewiesen, mit dem Grund. Das Schliessen zum
-- Stichtag kommt, wenn org_units überall gegen den Stichtag gelesen wird —
-- dieselbe Arbeit, die auch das Verschieben braucht.
create or replace function delete_org_unit(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_id uuid := (payload->>'org_unit_id')::uuid;
v_unit record;
v_einheiten int;
v_stellen int;
begin
perform require_hr_admin();
select id, org_number, name, parent_id into v_unit from org_units where id = v_id;
if v_unit.id is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_unit.parent_id is null then
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht entfernt werden.';
end if;
select count(*) into v_einheiten from org_units where parent_id = v_id;
if v_einheiten > 0 then
raise exception 'Unter dieser Einheit hängen noch % Einheiten. Sie müssen zuerst woanders hin.', v_einheiten;
end if;
-- Geschlossene Planstellen zählen mit. Sie tragen weiterhin diese Einheit,
-- und mit ihr verschwände die Angabe, wo jemand einmal gesessen hat.
select count(*) into v_stellen from om_positions where org_unit_id = v_id;
if v_stellen > 0 then
raise exception
'In dieser Einheit bestehen noch % Planstellen (geschlossene zählen mit). Sie müssen zuerst entfernt oder verschoben werden.',
v_stellen;
end if;
-- Die Kostenstellen tragen die Einheit nur als Hinweis (siehe Migration
-- 20260816100000), nicht als Zuordnung. Sie stünden nach dem Löschen auf
-- einer Einheit, die es nicht mehr gibt — der Fremdschlüssel würde das
-- abweisen, also vorher und mit Grund.
if exists (select 1 from cost_centers where org_unit_id = v_id) then
raise exception 'Auf diese Einheit verweisen noch Kostenstellen.';
end if;
delete from org_units where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit gelöscht',
v_unit.org_number || ' · ' || v_unit.name,
'Ohne untergeordnete Einheiten und ohne Planstellen');
end;
$$;
comment on function delete_org_unit(jsonb) is
'Entfernt eine Organisationseinheit, unter der nichts hängt und nie etwas hing. Einheiten mit Planstellen — auch geschlossenen — oder mit untergeordneten Einheiten werden abgewiesen; ein Schliessen zum Stichtag gibt es noch nicht, weil org_units nirgends gegen den Stichtag gelesen wird.';
grant execute on function delete_org_unit(jsonb) to alpenwerk_app;
-- Selbstprüfung.
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 = 'delete_org_unit' and p.prokind = 'f';
if v_def is null then
raise exception 'delete_org_unit wurde nicht angelegt';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'delete_org_unit prüft die Rechte nicht';
end if;
if v_def not like '%search_path%' then
raise exception 'delete_org_unit hat keinen festen search_path';
end if;
if v_def not like '%audit_log%' then
raise exception 'delete_org_unit schreibt nichts ins Protokoll';
end if;
-- Die drei Abweisungen. Fällt eine bei einer Neuerzeugung weg, löscht die
-- Funktion weiter — nur mehr, als sie darf.
if v_def not like '%om_positions%' then
raise exception 'delete_org_unit prüft die Planstellen nicht';
end if;
if v_def not like '%parent_id = v_id%' then
raise exception 'delete_org_unit prüft die untergeordneten Einheiten nicht';
end if;
if v_def not like '%v_unit.parent_id is null%' then
raise exception 'delete_org_unit schützt die Wurzel nicht';
end if;
-- Das Gegenstück muss weiterhin dastehen: ein Löschen ohne Anlegen wäre
-- eine Einbahnstrasse.
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 = 'create_org_unit'
) then
raise exception 'create_org_unit fehlt';
end if;
end
$$;

View File

@@ -0,0 +1,103 @@
-- Wiedereintritt: der Umwandlungsschritt beim Status, zum zweiten Mal
--
-- rehire_employee scheitert bei jedem Aufruf mit
--
-- column "status" is of type employment_status but expression is of type text
--
-- Der case-Ausdruck liefert `text` (beide Zweige sind Literale ohne Typ), die
-- Spalte ist ein Aufzählungstyp, und Postgres wandelt beim Zuweisen nicht von
-- selbst um. Genau dieser Fehler wurde am 10.08. mit
-- 20260810130000_fix_rehire_status_cast.sql behoben.
--
-- ═══ Warum er wieder da ist ═══════════════════════════════════════
--
-- Vier spätere Migrationen haben rehire_employee neu erzeugt und dabei den
-- Zusatz nicht mitgenommen: 20260917100000 (Austrittsart), 20260917130000
-- (Wiedereintritt mit Stammdaten), 20260923120000 (Firmen-E-Mail) und
-- 20260928100000 (Cornerstone-ID).
--
-- Die ersten beiden haben den Rumpf aus einer älteren Datei kopiert — der
-- Fall, vor dem der Abschnitt „create or replace aus veraltetem Muster" warnt.
-- Die letzten beiden sind aus der **laufenden** Definition erzeugt worden und
-- haben damit getreu weitergetragen, was seit dem 17.09. dort stand. Daraus
-- die Lehre, die vorher nicht dastand: das Erzeugen aus dem laufenden Stand
-- schützt davor, Verhalten zu **verlieren** — nicht davor, einen bereits
-- vorhandenen Fehler **mitzunehmen**. Dagegen hilft nur eine Selbstprüfung,
-- die das Erwartete benennt, statt nur das Neue zu prüfen.
--
-- Deshalb steht unten nicht bloss „der Zusatz ist da", sondern eine Prüfung,
-- die jede künftige Neuerzeugung dieser Funktion mitnehmen kann.
do $migration$
declare
v_alt constant text := $anker$status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,$anker$;
v_neu constant text := $anker$status = (case when v_date <= current_date then 'Aktiv' else 'Geplant' end)::employment_status,$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 = 'rehire_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'rehire_employee ist nicht vorhanden.';
end if;
-- Schon in Ordnung? Dann nichts tun. Die Migration soll auch auf einer
-- Datenbank durchlaufen, auf der die Funktion aus einer künftigen Datei mit
-- Umwandlung entstanden ist.
if v_def like '%end)::employment_status%' then
raise notice 'rehire_employee wandelt den Status bereits um.';
return;
end if;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Die Statuszuweisung kommt % mal vor, erwartet genau einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- Selbstprüfung.
--
-- Geprüft wird die Umwandlung **und** alles, was an dieser Funktion schon
-- einmal verlorengegangen ist. Wer sie das nächste Mal neu erzeugt, kopiert
-- diesen Block am besten mit.
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 = 'rehire_employee' and p.prokind = 'f';
if v_def not like '%end)::employment_status%' then
raise exception 'rehire_employee wandelt den Status nicht um — der Wiedereintritt scheitert bei jedem Aufruf.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'rehire_employee prueft die Rechte nicht.';
end if;
if v_def not like '%search_path%' then
raise exception 'rehire_employee hat keinen festen search_path.';
end if;
-- Die Stammdaten, die der Wiedereintritt seit 20260917130000 mitschreibt.
-- Ohne sie zeigt der Assistent Felder an und wirft ihre Werte weg.
if v_def not like '%cornerstone_id%' then
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
end if;
if v_def not like '%company_email%' then
raise exception 'rehire_employee hat die Firmen-E-Mail verloren.';
end if;
if v_def not like '%title_prefix%' then
raise exception 'rehire_employee hat die Titel verloren.';
end if;
if v_def not like '%paygrade%' then
raise exception 'rehire_employee hat den Hay-Grade verloren.';
end if;
raise notice 'rehire_employee: Statusumwandlung und Stammdaten vollstaendig.';
end
$$;

View File

@@ -0,0 +1,177 @@
-- Versetzung und Beförderung am selben Tag, an dem die Besetzung begann
--
-- Gemeldet beim Nachtest am 29.09.: eine Beförderung auf eine andere
-- Planstelle scheiterte mit
--
-- new row for relation "position_assignments" violates check constraint
-- "chk_assignment_range"
--
-- Die Person war am selben Tag schon einmal versetzt worden. Ihre laufende
-- Besetzung begann also heute, und der Code schliesst sie auf denselben Tag:
--
-- update position_assignments set valid_to = <wirksam ab> where valid_to is null;
-- insert into position_assignments (…) values (…, <wirksam ab>);
--
-- `chk_assignment_range` verlangt `valid_to > valid_from`; ein Intervall von
-- null Tagen ist verboten. Das ist richtig so — und der Fall ist im Haus auch
-- schon einmal gelöst worden: terminate_employee **löscht** die Besetzung beim
-- Grund „No Show", statt sie auf [Eintritt, Eintritt) zu schliessen, mit der
-- Begründung, dass niemand sie je innehatte. Dieselbe Begründung gilt hier:
-- eine Besetzung, die am Tag ihres Beginns wieder endet, hat nicht
-- stattgefunden.
--
-- Die Alternative — die zweite Versetzung am selben Tag abzuweisen — wäre
-- genau der häufigste Fall: jemand hat die falsche Planstelle erwischt und
-- will es sofort richtigstellen. Die Ereignisse in employee_history bleiben
-- beide stehen; sichtbar ist also weiterhin, dass zweimal verschoben wurde.
--
-- Betroffen sind vier Stellen mit demselben Paar aus Schliessen und Einfügen:
-- transfer_employee, promote_employee und im Nachtlauf die Zweige `transfer`
-- und `promotion`. Alle vier werden aus der laufenden Definition gelesen und
-- an einem Anker ergänzt.
-- ═══ 1. transfer_employee ═════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_effective$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective;
update position_assignments set valid_to = v_effective$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 = 'transfer_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'transfer_employee 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 Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 2. promote_employee ══════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_effective_date$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective_date;
update position_assignments set valid_to = v_effective_date$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 = 'promote_employee' and p.prokind = 'f';
if v_def is null then
raise exception 'promote_employee 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 Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 3. Der Nachtlauf, beide Zweige ═══════════════════════════════
--
-- Hier steht dasselbe zweimal — bei `transfer` und bei `promotion` mit
-- Zielplanstelle. Beide werden ersetzt, und die Zahl wird geprüft: der Zweig
-- `transfer` ist in diesem Projekt schon einmal spurlos verschwunden.
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_rec.effective_date$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_rec.employee_id and valid_to is null and valid_from >= v_rec.effective_date;
update position_assignments set valid_to = v_rec.effective_date$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 = '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;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 2 then
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet zweimal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
do $$
declare
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 = 'transfer_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective%' then
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'transfer_employee prueft die Rechte nicht.';
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 = 'promote_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee prueft die Rechte nicht.';
end if;
-- Was an dieser Funktion schon einmal verlorenging.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle 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';
v_anzahl := (length(v_def) - length(replace(v_def, 'valid_from >= v_rec.effective_date', '')))
/ length('valid_from >= v_rec.effective_date');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf raeumt an % von 2 Stellen ab.', v_anzahl;
end if;
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
/ length('insert into position_assignments');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
end if;
if v_def not like '%v_rec.payload->''role''%' then
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
end if;
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;
raise notice 'Besetzung am selben Tag: alle vier Stellen abgeraeumt.';
end
$$;

View File

@@ -0,0 +1,250 @@
-- Eine Planstelle, auf die jemand vorgemerkt ist, ist nicht frei
--
-- Gemeldet im Test vom 29.09. (F.24): eine Person wurde auf eine Planstelle
-- eingestellt, auf die bereits eine Versetzung zum 20.10. vorgemerkt war. Die
-- Planstellenliste führte die Stelle als unbesetzt, der Assistent bot sie an,
-- und das Organigramm zum 20.10. zeigte sie zweifach besetzt.
--
-- ═══ Warum das schlimmer ist, als es aussieht ═════════════════════
--
-- Eine vorgemerkte Versetzung steht in pending_org_changes und noch nicht in
-- position_assignments. „Besetzt?" wird aber allein an position_assignments
-- gefragt — die Stelle ist zum Zeitpunkt der Buchung tatsächlich frei.
--
-- Auffallen würde es erst in der Nacht zum 20.10.: dann legt der Nachtlauf die
-- Besetzung der vorgemerkten Person an und läuft in
-- `position_assignments_one_holder`, den Teilindex, der genau eine laufende
-- Besetzung je Planstelle zulässt. Der Nachtlauf arbeitet in **einer**
-- Transaktion über alle fälligen Vorgänge; eine einzige solche Buchung bringt
-- ihn also vollständig zum Stehen — auch für alle anderen. Der Fehler entsteht
-- heute und schlägt Wochen später bei jemand anderem zu.
--
-- ═══ Was hier steht ═══════════════════════════════════════════════
--
-- Eine Auskunft, die pending_org_changes mitliest, und ihr Aufruf in den vier
-- Funktionen, die jemanden auf eine Planstelle setzen: hire_employee,
-- rehire_employee, transfer_employee, promote_employee.
--
-- Abgewiesen wird mit Datum — „ab dem 20.10.2026 vorgemerkt" sagt der Person
-- vor dem Bildschirm, was zu tun ist. Die eigene Vormerkung zählt nicht als
-- Hindernis: wer dieselbe Versetzung noch einmal erfasst, korrigiert sie.
create or replace function planstelle_vorgemerkt(p_position_id uuid, p_ab date, p_ausser uuid default null)
returns date language sql stable
set search_path = public, pg_temp
as $$
select min(o.effective_date)
from pending_org_changes o
where o.status = 'pending'
-- Nur die beiden Vorgänge, die eine Planstelle belegen. Eine
-- Vertragsänderung oder eine Karenz trägt keine Zielplanstelle.
and o.change_type in ('transfer', 'promotion')
and o.effective_date >= p_ab
and (p_ausser is null or o.employee_id <> p_ausser)
and nullif(o.payload->>'target_position_id', '')::uuid = p_position_id;
$$;
comment on function planstelle_vorgemerkt(uuid, date, uuid) is
'Ab wann eine Planstelle durch eine vorgemerkte Versetzung oder Beförderung belegt ist, oder null. Die Vormerkung der übergebenen Person zählt nicht mit.';
grant execute on function planstelle_vorgemerkt(uuid, date, uuid) to alpenwerk_app;
-- ═══ hire_employee ════════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_entry, null);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$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 = 'hire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'hire_employee 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 Anker in hire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ rehire_employee ══════════════════════════════════════════════
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$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 = 'rehire_employee' and p.prokind = 'f';
if v_def is null then raise exception 'rehire_employee 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 Anker in rehire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ transfer_employee ════════════════════════════════════════════
--
-- Der Anker steht auf der zweiten Zeile der Belegungsabfrage. Die erste
-- Fassung dieser Migration zielte auf
--
-- where pa.position_id = v_target_position and pa.valid_to is null;
--
-- also auf den Wortlaut aus 20260727120200 — und fand nichts. Die Funktion ist
-- seither dynamisch gepatcht worden: die Abfrage ist zweizeilig und
-- berücksichtigt den Stichtag. Dieselbe Falle wie immer, nur diesmal nicht im
-- Rumpf einer Funktion, sondern im Anker auf sie.
do $migration$
declare
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);$anker$;
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_target_position, v_effective, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$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 = 'transfer_employee' and p.prokind = 'f';
if v_def is null then raise exception 'transfer_employee 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 Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ promote_employee ═════════════════════════════════════════════
--
-- Ohne Zielplanstelle ist v_position null; die Auskunft liefert dann nichts,
-- und der Zweig läuft durch. Eine Beförderung auf derselben Stelle braucht
-- deshalb keine Sonderbehandlung.
do $migration$
declare
v_alt constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;$anker$;
v_neu constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;
declare
v_vorgemerkt date := planstelle_vorgemerkt(v_position, v_effective_date, v_employee_id);
begin
if v_vorgemerkt is not null then
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
to_char(v_vorgemerkt, 'DD.MM.YYYY');
end if;
end;
$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 = 'promote_employee' and p.prokind = 'f';
if v_def is null then raise exception 'promote_employee 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 Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ Selbstprüfung ════════════════════════════════════════════════
do $$
declare
v_name text;
v_def text;
begin
foreach v_name in array array['hire_employee', 'rehire_employee', 'transfer_employee', 'promote_employee'] loop
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 = v_name and p.prokind = 'f';
if v_def not like '%planstelle_vorgemerkt(%' then
raise exception '% fragt die vorgemerkten Planstellen nicht ab.', v_name;
end if;
if v_def not like '%require_hr_admin()%' then
raise exception '% prueft die Rechte nicht.', v_name;
end if;
if v_def not like '%search_path%' then
raise exception '% hat keinen festen search_path.', v_name;
end if;
end loop;
-- Was an diesen Funktionen schon einmal verlorenging. Die Statusumwandlung
-- im Wiedereintritt ist zweimal verschwunden; sie wird hier mitgeprüft,
-- weil diese Migration die Funktion erneut erzeugt.
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 = 'rehire_employee' and p.prokind = 'f';
if v_def not like '%end)::employment_status%' then
raise exception 'rehire_employee wandelt den Status nicht um.';
end if;
if v_def not like '%cornerstone_id%' then
raise exception 'rehire_employee hat die Cornerstone-ID 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 = 'promote_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle 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 = 'transfer_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_effective%' then
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
raise notice 'Vorgemerkte Planstellen: alle vier Buchungswege pruefen sie.';
end
$$;

View File

@@ -0,0 +1,76 @@
-- Austritt am Tag, an dem die Besetzung begann
--
-- Die fünfte Stelle mit demselben Paar aus Schliessen und Einfügen, gefunden
-- beim Schreiben des Rauchtests: terminate_employee schliesst die Besetzung auf
-- das Austrittsdatum. Wer heute eingestellt und heute wieder ausgetragen wird —
-- ohne den Grund „No Show" —, läuft damit in chk_assignment_range.
--
-- Die No-Show-Hälfte derselben Funktion macht es längst richtig und erklärt
-- auch, warum: eine Besetzung von null Tagen hat nicht stattgefunden, also wird
-- sie entfernt statt geschlossen. Nur galt das bisher für genau einen
-- Austrittsgrund. Ein Eintritt am selben Tag zurückgenommen, aber mit
-- „Einvernehmliche Auflösung" statt „No Show", scheiterte an der rohen
-- Datenbankmeldung.
--
-- Damit sind es fünf: hire/rehire brauchen es nicht (sie legen an, sie
-- schliessen nicht), transfer_employee, promote_employee und die beiden Zweige
-- des Nachtlaufs stehen seit 20260929180000, terminate_employee kommt hier
-- dazu.
do $migration$
declare
v_alt constant text := $anker$update position_assignments set valid_to = v_exit$anker$;
v_neu constant text := $anker$delete from position_assignments
where employee_id = v_employee_id and valid_to is null and valid_from >= v_exit;
update position_assignments set valid_to = v_exit$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 = 'terminate_employee' and p.prokind = 'f';
if v_def is null then raise exception 'terminate_employee 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 Anker in terminate_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- Selbstprüfung: das Neue und alles, was an dieser Funktion schon einmal
-- verlorengegangen ist. Die Anmerkung ist beim Umbau auf No-Show einmal
-- stillschweigend verschwunden und fiel erst durch eine Frage des Kunden auf.
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 = 'terminate_employee' and p.prokind = 'f';
if v_def not like '%valid_from >= v_exit%' then
raise exception 'terminate_employee raeumt die Besetzung vom selben Tag nicht ab.';
end if;
if v_def not like '%No Show%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr.';
end if;
if v_def not like '%v_note%' then
raise exception 'terminate_employee schreibt die Anmerkung nicht.';
end if;
if v_def not like '%austrittsart%' then
raise exception 'terminate_employee schreibt die Austrittsart nicht.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'terminate_employee prueft die Rechte nicht.';
end if;
if v_def not like '%search_path%' then
raise exception 'terminate_employee hat keinen festen search_path.';
end if;
raise notice 'terminate_employee: Austritt am selben Tag abgeraeumt.';
end
$$;

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

@@ -0,0 +1,252 @@
-- Eine Beförderung benennt die Planstelle um
--
-- Bisher schrieb promote_employee den neuen Titel nach employees.job_title —
-- und **nur** dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die
-- Exportdateien zeigen alle die Tätigkeit der Planstelle. Schlimmer noch, der
-- Wert war nicht einmal haltbar: hire_employee, transfer_employee und
-- change_employee_data setzen dieselbe Spalte aus der Planstelle, also
-- überschrieb die nächste Adressänderung den Titel wieder.
--
-- Gemeldet von Lara („in der zusammenfassung der person ist jedoch noch
-- Marketingtätigkeit angeführt") und im Testprotokoll als H.10. Max hat am
-- 30.09. entschieden: die Beförderung benennt die Planstelle um.
--
-- ═══ Welche Planstelle ════════════════════════════════════════════
--
-- Die, auf der die Person **danach** sitzt: die Zielplanstelle, wenn eine
-- gewählt wurde, sonst die bisherige. Ein Titel, der zu keiner Stelle gehört,
-- war ja gerade das Problem.
--
-- Im Formular ist das ohne Überraschung, weil es beim Wählen einer
-- Zielplanstelle deren Tätigkeit als Vorschlag einträgt: wer nichts ändert,
-- benennt auch nichts um.
--
-- ═══ Der Katalog ═════════════════════════════════════════════════
--
-- Umbenannt wird nicht der Katalogeintrag, sondern die Planstelle zeigt auf
-- einen anderen. Den Eintrag selbst umzubenennen träfe **jede** Planstelle mit
-- derselben Tätigkeit — bei Manner hängen an einem Eintrag bis zu fünf.
--
-- Die Regel „gleiche Tätigkeit, ein Katalogeintrag" stand bisher nur in
-- create_position. Sie wandert in job_fuer_titel() und wird von dort aus
-- benutzt; zwei Fassungen derselben Regel laufen in diesem Projekt
-- erfahrungsgemäss auseinander.
create or replace function job_fuer_titel(p_titel text)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_titel text := nullif(btrim(p_titel), '');
v_id uuid;
begin
if v_titel is null then
raise exception 'Es muss eine Tätigkeit angegeben werden.';
end if;
-- Ohne Rücksicht auf Gross- und Kleinschreibung: sonst stehen
-- „Schlosser:in" und „schlosser:in" nebeneinander und jede Auswertung nach
-- Tätigkeit ist wertlos.
select id into v_id from jobs where lower(title) = lower(v_titel);
if v_id is not null then
return v_id;
end if;
-- Die erste freie Nummer statt count(*) + 1. Gezählt wurde bisher, und das
-- vergibt eine bereits belegte Nummer, sobald ein Eintrag gelöscht wurde
-- oder — wie bei der Übernahme der Manner-Daten — Codes aus einer fremden
-- Quelle danebenstehen.
insert into jobs (code, title)
select 'J' || lpad(n::text, 4, '0'), v_titel
from generate_series(1, 100000) n
where not exists (select 1 from jobs j where j.code = 'J' || lpad(n::text, 4, '0'))
order by n
limit 1
returning id into v_id;
if v_id is null then
raise exception 'Es ist keine freie Jobnummer mehr frei.';
end if;
return v_id;
end;
$$;
comment on function job_fuer_titel(text) is
'Der Katalogeintrag zu einer Tätigkeit — vorhandener oder neuer. Eine Tätigkeit, ein Eintrag, ohne Rücksicht auf Gross- und Kleinschreibung.';
grant execute on function job_fuer_titel(text) to alpenwerk_app;
-- ═══ 1. create_position benutzt die Auskunft statt einer zweiten Fassung ══
do $migration$
declare
v_alt constant text := $anker$ select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;$anker$;
v_neu constant text := $anker$ v_job_id := job_fuer_titel(v_job_title);$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 = 'create_position' and p.prokind = 'f';
if v_def is null then raise exception 'create_position 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 Anker in create_position kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 2. promote_employee benennt die Planstelle um ════════════════
do $migration$
declare
v_alt constant text := $anker$ update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;$anker$;
v_neu constant text := $anker$ update employees set
job_title = payload->>'new_title',
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
-- Die Bezeichnung gehört zur Planstelle, nicht zur Person: dort lesen sie
-- Akte, Organigramm und die Exporte. Betroffen ist die Stelle, auf der die
-- Person danach sitzt — die Zielstelle, sonst die bisherige.
declare
v_umbenennen uuid := coalesce(v_position, (
select pa.position_id from position_assignments pa
where pa.employee_id = v_employee_id and pa.valid_to is null));
begin
if v_umbenennen is not null then
update om_positions set job_id = job_fuer_titel(payload->>'new_title')
where id = v_umbenennen;
end if;
end;$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 = 'promote_employee' and p.prokind = 'f';
if v_def is null then raise exception 'promote_employee 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 Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ 3. Der Nachtlauf, Zweig `promotion` ══════════════════════════
--
-- Eine auf später datierte Beförderung muss dasselbe tun, sonst benennt sie
-- am Stichtag nichts um und der Titel wäre wieder nur an der Person — genau
-- der Zustand, den diese Migration beendet.
do $migration$
declare
v_alt constant text := $anker$ update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;$anker$;
v_neu constant text := $anker$ update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
if nullif(v_rec.payload->>'new_title', '') is not null then
declare
v_umbenennen uuid := coalesce(
nullif(v_rec.payload->>'target_position_id', '')::uuid, (
select pa.position_id from position_assignments pa
where pa.employee_id = v_rec.employee_id and pa.valid_to is null));
begin
if v_umbenennen is not null then
update om_positions set job_id = job_fuer_titel(v_rec.payload->>'new_title')
where id = v_umbenennen;
end if;
end;
end if;$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 = '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;
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
if v_anzahl <> 1 then
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet einmal.', v_anzahl;
end if;
execute replace(v_def, v_alt, v_neu);
end
$migration$;
-- ═══ Selbstprüfung ════════════════════════════════════════════════
do $$
declare
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 = 'create_position' and p.prokind = 'f';
if v_def not like '%job_fuer_titel(%' then
raise exception 'create_position benutzt die gemeinsame Auskunft nicht.';
end if;
if v_def like '%count(*) + 1 from jobs%' then
raise exception 'create_position traegt noch die zweite Fassung der Katalogregel.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'create_position prueft die Rechte nicht.';
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 = 'promote_employee' and p.prokind = 'f';
if v_def not like '%update om_positions set job_id = job_fuer_titel%' then
raise exception 'promote_employee benennt die Planstelle nicht um.';
end if;
-- Was an dieser Funktion schon einmal verlorenging.
if v_def not like '%target_position_id%' then
raise exception 'promote_employee hat die Zielplanstelle verloren.';
end if;
if v_def not like '%valid_from >= v_effective_date%' then
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
end if;
if v_def not like '%planstelle_vorgemerkt(%' then
raise exception 'promote_employee prueft die vorgemerkten Planstellen nicht mehr.';
end if;
if v_def not like '%require_hr_admin()%' then
raise exception 'promote_employee prueft die Rechte nicht.';
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 '%job_fuer_titel(v_rec.payload%' then
raise exception 'Der Nachtlauf benennt die Planstelle nicht um.';
end if;
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
/ length('insert into position_assignments');
if v_anzahl <> 2 then
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
end if;
if v_def not like '%v_rec.payload->''role''%' then
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
end if;
raise notice 'Befoerderung benennt die Planstelle um — sofort und im Nachtlauf.';
end
$$;

223
db/tests/rauchtest.sql Normal file
View File

@@ -0,0 +1,223 @@
-- Rauchtest: der Lebenszyklus, einmal wirklich ausgeführt
--
-- docker compose run --rm psql -f /repo/db/tests/rauchtest.sql
--
-- ═══ Warum es das gibt ════════════════════════════════════════════
--
-- rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt: ein fehlender
-- Umwandlungsschritt beim Status. Aufgefallen ist es erst beim Handtest am
-- 29.09. Keine Prüfung der Anwendung hat es bemerkt, und keine konnte es:
-- die Selbstprüfungen der Migrationen lesen den **Text** der Funktionen, die
-- Unit-Tests laufen ohne Datenbank, und die Integrationstests haben keinen
-- Bestand, gegen den sie liefen.
--
-- Diese Datei schliesst genau diese Lücke: sie **ruft** die Funktionen auf,
-- gegen die echte Datenbank, in der Reihenfolge des Lebenszyklus.
--
-- ═══ Zwei Eigenschaften, ohne die es gefährlich wäre ══════════════
--
-- 1. **Es wird zurückgerollt.** Alles läuft in einer Transaktion, die am Ende
-- verworfen wird. Nach dem Lauf steht kein Testmensch, keine Testplanstelle
-- und keine Testeinheit in der Datenbank. Der Anlass steht daneben: beim
-- Test vom 29.09. sind reale Personen auf Testplanstellen umgezogen und
-- dort geblieben.
--
-- 2. **Es läuft als Anwendungsrolle.** psql verbindet sich als Superuser, und
-- der geht an jeder Zeilenschutz-Regel und an require_hr_admin() vorbei.
-- Ein Lauf als postgres würde beweisen, dass die Funktionen für niemanden
-- funktionieren, der sie benutzt.
--
-- Fehlschläge brechen mit einer Meldung ab, die den Schritt nennt. Kommt der
-- Lauf bis „Rauchtest bestanden" durch, ist der Lebenszyklus gegangen worden.
\set ON_ERROR_STOP on
begin;
select set_config(
'app.user_id',
(select p.id::text from profiles p where p.role = 'hr' and p.is_active limit 1),
true
) as angemeldet_als;
set local role alpenwerk_app;
do $rauchtest$
declare
v_wurzel uuid;
v_einheit uuid;
v_p1 uuid; v_p2 uuid; v_p3 uuid; v_p4 uuid;
v_a uuid; v_b uuid;
v_ort uuid;
v_nr int;
v_status text;
v_stelle uuid;
v_offen int;
v_meldung text;
v_ging boolean;
v_titel text;
begin
select id into v_wurzel from org_units where parent_id is null;
select id into v_ort from locations order by name limit 1;
select coalesce(max(personnel_number), 0) + 1 into v_nr from employees;
if v_wurzel is null or v_ort is null then
raise exception 'Kein Bestand: es fehlt die oberste Einheit oder ein Standort.';
end if;
-- ── Spielwiese ──────────────────────────────────────────────────
v_einheit := create_org_unit(jsonb_build_object(
'parent_id', v_wurzel, 'org_number', 'RAUCHTEST', 'name', 'Rauchtest',
'unit_type', 'Team', 'valid_from', (current_date - 30)::text));
v_p1 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Eins', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p2 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Zwei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p3 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Drei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
v_p4 := create_position(jsonb_build_object('org_unit_id', v_einheit,
'job_title', 'Rauchtest Vier', 'is_chief', false, 'valid_from', (current_date - 30)::text));
raise notice '1/8 Einheit und vier Planstellen angelegt';
-- ── Einstellung ─────────────────────────────────────────────────
v_a := hire_employee(jsonb_build_object(
'personnel_number', v_nr, 'first_name', 'Rauch', 'last_name', 'Test-A',
'gender', 'w', 'birth_date', '1990-01-01', 'location_id', v_ort,
'position_id', v_p1, 'entry_date', current_date::text));
raise notice '2/8 Einstellung auf Planstelle 1';
-- ── Versetzung am selben Tag (20260929180000) ───────────────────
-- Die Besetzung begann heute; sie auf heute zu schliessen wäre ein
-- Intervall von null Tagen. Vorher: chk_assignment_range.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p2, 'effective_date', current_date::text));
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p2 then
raise exception 'Nach der Versetzung sitzt die Person nicht auf Planstelle 2.';
end if;
raise notice '3/8 Versetzung am selben Tag';
-- ── Beförderung mit Planstellenwechsel, ebenfalls heute ─────────
perform promote_employee(jsonb_build_object(
'employee_id', v_a, 'effective_date', current_date::text,
'new_title', 'Rauchtest Leitung', 'new_paygrade', 'HG15',
'target_position_id', v_p3));
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p3 then
raise exception 'Nach der Beförderung sitzt die Person nicht auf Planstelle 3.';
end if;
if (select paygrade::text from employees where id = v_a) <> 'HG15' then
raise exception 'Die Beförderung hat den Hay-Grade nicht gesetzt.';
end if;
-- Die Bezeichnung gehört zur Planstelle: dort lesen sie Akte, Organigramm
-- und die Exporte. Vorher stand der neue Titel allein an der Person und war
-- nirgends zu sehen.
select j.title into v_titel
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
if v_titel <> 'Rauchtest Leitung' then
raise exception 'Die Zielplanstelle heisst nach der Beförderung „%", erwartet „Rauchtest Leitung".', v_titel;
end if;
raise notice '4/8 Beförderung am selben Tag: Hay-Grade und Planstellenbezeichnung';
-- Dieselbe Planstelle, nur ein neuer Name — der Fall aus dem Testtag.
perform promote_employee(jsonb_build_object(
'employee_id', v_a, 'effective_date', current_date::text,
'new_title', 'Rauchtest Leitung Senior', 'new_paygrade', 'HG16'));
select j.title into v_titel
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
if v_titel <> 'Rauchtest Leitung Senior' then
raise exception 'Ohne Stellenwechsel heisst die Planstelle „%", erwartet „Rauchtest Leitung Senior".', v_titel;
end if;
-- Der Katalogeintrag der alten Bezeichnung bleibt stehen: an ihm können
-- andere Planstellen hängen. Umbenannt wird die Stelle, nicht der Katalog.
if not exists (select 1 from jobs where title = 'Rauchtest Leitung') then
raise exception 'Der alte Katalogeintrag wurde umbenannt statt die Planstelle umgehängt.';
end if;
raise notice '4b/8 Beförderung ohne Stellenwechsel benennt dieselbe Planstelle um';
-- ── Vorgemerkte Planstelle (20260929200000) ─────────────────────
-- Eine Versetzung in die Zukunft belegt die Zielstelle, obwohl dort noch
-- keine Besetzung steht. Wer sie vorher besetzt, bringt den Nachtlauf zum
-- Stehen — vorher ging genau das durch.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 10)::text));
-- Der Fehlschlag wird **ausserhalb** des Blocks ausgewertet. Ein `raise`
-- innerhalb landet im eigenen exception-Zweig — und weil seine Meldung das
-- Wort „vorgemerkte" enthielt, bestand der Schritt beim ersten Lauf, obwohl
-- die Migration gar nicht angewendet war. Ein Test, der sich selbst
-- durchwinkt, ist schlimmer als keiner.
v_ging := false;
begin
perform hire_employee(jsonb_build_object(
'personnel_number', v_nr + 1, 'first_name', 'Rauch', 'last_name', 'Test-B',
'gender', 'm', 'birth_date', '1990-01-01', 'location_id', v_ort,
'position_id', v_p4, 'entry_date', current_date::text));
v_ging := true;
exception
when others then v_meldung := SQLERRM;
end;
if v_ging then
raise exception 'Die vorgemerkte Planstelle liess sich besetzen — die Pruefung greift nicht.';
end if;
if v_meldung not like '%bereits eine Versetzung vorgemerkt%' then
raise exception 'Erwartet wurde die Meldung zur Vormerkung, kam aber: %', v_meldung;
end if;
raise notice '5/8 Vorgemerkte Planstelle wird abgewiesen';
-- Die eigene Vormerkung darf nicht im Weg stehen: dieselbe Versetzung noch
-- einmal zu erfassen ist eine Berichtigung.
perform transfer_employee(jsonb_build_object(
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 20)::text));
raise notice '6/8 Die eigene Vormerkung steht nicht im Weg';
-- ── Austritt am selben Tag (20260929220000) ─────────────────────
perform terminate_employee(jsonb_build_object(
'employee_id', v_a, 'exit_date', current_date::text,
'exit_reason', 'Einvernehmliche Auflösung', 'austrittsart', 'freiwillig'));
select count(*) into v_offen from position_assignments
where employee_id = v_a and valid_to is null;
if v_offen <> 0 then
raise exception 'Nach dem Austritt ist die Planstelle nicht frei.';
end if;
raise notice '7/8 Austritt am selben Tag, Planstelle frei';
-- ── Wiedereintritt (20260929160000, K.01) ───────────────────────
perform rehire_employee(jsonb_build_object(
'employee_id', v_a, 'rehire_date', current_date::text, 'position_id', v_p1,
'first_name', 'Rauch', 'last_name', 'Test-A', 'gender', 'w',
'birth_date', '1990-01-01', 'nationality', 'Österreich', 'location_id', v_ort));
select status::text into v_status from employees where id = v_a;
if v_status <> 'Aktiv' then
raise exception 'Nach dem Wiedereintritt steht der Status auf %, erwartet Aktiv.', v_status;
end if;
select position_id into v_stelle from position_assignments
where employee_id = v_a and valid_to is null;
if v_stelle is distinct from v_p1 then
raise exception 'Nach dem Wiedereintritt sitzt die Person nicht auf Planstelle 1.';
end if;
raise notice '8/8 Wiedereintritt';
raise notice '';
raise notice 'Rauchtest bestanden. Alles Angelegte wird gleich verworfen.';
end
$rauchtest$;
rollback;
-- Beweis, dass nichts geblieben ist. Läuft ausserhalb der verworfenen
-- Transaktion und muss überall 0 liefern.
select
(select count(*) from org_units where org_number = 'RAUCHTEST') as einheiten,
(select count(*) from employees where last_name like 'Test-%' and first_name = 'Rauch') as personen,
(select count(*) from jobs where title like 'Rauchtest %') as taetigkeiten;

View File

@@ -108,7 +108,7 @@ die kommt über die Planstelle.
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
| `paygrade` | `paygrade_type` | – | `B` | A–F |
| `paygrade` | `paygrade_type` | – | `-` | Hay-Grade; der Spaltenname ist geblieben |
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
@@ -304,7 +304,7 @@ eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026
| `contract_type` | unbefristet, befristet |
| `worker_type` | Angestellte:r, Arbeiter:in, Lehrling |
| `collective_agreement` | Handel, Süßwaren |
| `paygrade_type` | A, B, C, D, E, F |
| `paygrade_type` | -, HG09…HG19, HG19P, HG20 (Domain über text, kein Aufzählungstyp) |
| `source_type` | Intern, Extern |
| `gender_type` | m, w |
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |

View File

@@ -50,7 +50,20 @@ export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzei
* geregelte Teilzeiten, die man später wiederfinden können muss — im
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
*/
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
export const STUNDEN_GRUENDE = [
"Vertragliche Stundenänderung",
"Bildungsteilzeit",
"Pflegeteilzeit",
// Die beiden standen zuerst nur in RUECKKEHR_GRUENDE, weil sie typischerweise
// dann beginnen, wenn eine Abwesenheit endet. Typischerweise ist aber nicht
// immer: eine Elternteilzeit kann auch vereinbart werden, ohne dass in
// diesem System je eine Karenz stand — etwa beim zweiten Elternteil oder
// wenn die Karenz vor der Einführung lag. Gemeldet von Lara am 29.09.:
// „Teilzeitvariante Elternteilzeit fehlt". Der Bericht konnte schon immer
// nach allen vier filtern; erfassen liessen sich nur zwei.
"Elternteilzeit",
"Wiedereingliederungsteilzeit",
] as const;
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];

371
lib/cornerstone.ts Normal file
View File

@@ -0,0 +1,371 @@
import type { Kostenstelle } from "./cost-centers";
import type { CsvForm, ExportColumn } from "./export";
import type { EmploymentStatus } from "./types";
// Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge.
//
// Die Reihenfolge und die Schreibweise der Überschriften sind vorgegeben:
// Cornerstone ordnet die Spalten über eine Konfiguration zu, die im Portal
// eingerichtet wird. Eine umbenannte oder verschobene Spalte fällt hier nicht
// auf, sondern dort — als Feld, das leer ankommt.
//
// ═══ Was aus Alpenwerk kommt und was nicht ══════════════════════════
//
// Ein Teil der Spalten hat in Alpenwerk keine Entsprechung. Sie bleiben leer,
// so wie in der Beispielzeile, statt mit erfundenen Werten gefüllt zu werden:
// Approver, Absent, Allow Reconciliation, Required Training Approvals, Exempt,
// Ethnicity, State, Mailstop, Phone, Mobile, Fax, Retention Rules,
// User activation/de-activation date, Eligible For Rehire.
//
// Vier Spalten tragen ausdrücklich **Platzhalter**, weil die führenden
// Systeme ihre Kennungen noch nicht liefern: Division ID sowie die drei
// Customfields für Doxis, Interflex und LGVplus. Sie sind als solche erkennbar
// (siehe die Konstanten unten) und müssen vor einem echten Lauf ersetzt werden.
//
// ═══ Was bewusst NICHT mitgeht ══════════════════════════════════════
//
// Private Telefonnummer und private E-Mail-Adresse. Cornerstone hat dafür
// Spalten (Home Phone, Personal Email), und die Beispielzeile lässt beide
// leer. Dabei bleibt es: Daten, die das Haus verlassen, sind die dienstlichen.
/**
* Die Form der CSV-Datei, wie Cornerstone sie einliest.
*
* Steht hier und nicht in der Route, damit sie zusammen mit den Spalten
* geprueft werden kann: die Vorgaben von toCsv sind fuer Excel gemacht
* (Semikolon, BOM) und waeren hier beide falsch. Das BOM haenge unsichtbar am
* Namen der ersten Spalte, und die kaeme in keiner Zuordnung vor.
*/
export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false };
export const CORNERSTONE_SPRACHE = "de-DE";
export const CORNERSTONE_WAEHRUNG = "EUR";
/**
* Der Benutzertyp als **Systemwert**, nicht als Anzeigename.
*
* Cornerstone zeigt „Mitarbeiter" und erwartet beim Einlesen „Employee".
* Der Anzeigename führt zu „User Type ungültiger Wert" und danach zu
* „Alle abhängigen Felder müssen gültig sein" — einem Folgefehler, der
* verschwindet, sobald der erste behoben ist.
*/
export const CORNERSTONE_USER_TYPE = "Employee";
/**
* Die Zeitzone bleibt leer.
*
* Gültig ist allein eine Zeitzonen-ID aus dem Portal; ein Kürzel wie „CET"
* quittiert der Load mit „Zeitzonencode nicht eindeutig". Leer heisst:
* Vererbung vom Portal beziehungsweise von der Organisationseinheit.
*/
export const CORNERSTONE_ZEITZONE = "";
/**
* Die Division als GUID, übernommen aus dem Testload vom 27.09.2026.
*
* Cornerstone verlangt hier die GUID, nicht den Namen. Sie steht in keiner
* Alpenwerk-Tabelle — sie gehört dem Zielsystem. Vor einem Echtlauf gegen
* das Portal prüfen.
*/
export const DIVISION_GUID = "1f4c00b3-f0e7-4b53-b84e-e1f8d6088262";
export const DOXIS_PRAEFIX = "DOX-";
export const LGV_PRAEFIX = "LGV-";
/**
* Cornerstone kennt zwei Geschlechter nicht als „m"/„w".
*
* Die erlaubten Werte sind female, male, not known und not specified. Ein
* Wert ausserhalb davon lässt Cornerstone die Zeile abweisen — nicht das
* Feld, die ganze Zeile.
*/
export function geschlecht(g: string): string {
if (g === "m") return "male";
if (g === "w") return "female";
return "not specified";
}
/**
* Tag zuerst: 15.09.2026.
*
* Die Einleseeinstellung in Cornerstone ist auf „Tag zuerst" gestellt. Käme
* ein ISO-Datum an, läse Cornerstone den 09. März statt des 3. September —
* lautlos, weil beides gültige Daten sind.
*/
export function csDatum(iso: string | null | undefined): string {
if (!iso) return "";
const [j, m, t] = iso.slice(0, 10).split("-");
return j && m && t ? `${t}.${m}.${j}` : "";
}
/**
* Der Konto­name im Verzeichnisdienst: vorname.nachname, klein, ohne Umlaute.
*
* Nur für das Customfield AD. Der **Username** in Cornerstone ist etwas
* anderes: er ist die UUID aus Alpenwerk, dieselbe wie die User ID.
*
* Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse
* ist freiwillig, der Name ist es nicht.
*/
export function benutzername(vorname: string, nachname: string): string {
const sauber = (s: string) =>
s
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "");
return [sauber(vorname), sauber(nachname)].filter(Boolean).join(".");
}
/**
* Zwei Statusspalten, zwei verschiedene Fragen.
*
* `Status` ist das Konto in Cornerstone: wer ausgetreten ist, soll sich nicht
* mehr anmelden. `Employment Status` ist das Beschäftigungsverhältnis. Eine
* Person in Karenz hat ein aktives Konto und arbeitet trotzdem nicht — würde
* man beide Spalten aus demselben Wert füllen, verschwände sie entweder aus
* dem System oder sie gälte als arbeitend.
*/
export function kontoStatus(status: EmploymentStatus): string {
return status === "Ausgetreten" ? "Inactive" : "Active";
}
/**
* Working, On Leave oder Terminated — die Werte des Zielsystems.
*
* „Geplant" kennt Cornerstone nicht. Eine Person, deren Eintritt noch
* bevorsteht, wird als Working geladen: sie bekommt ein Konto, und das
* Eintrittsdatum steht ohnehin in der Zeile. Alles andere hiesse, sie
* entweder wegzulassen oder einen Wert zu erfinden.
*/
export function beschaeftigungsStatus(status: EmploymentStatus): string {
if (status === "Karenz") return "On Leave";
if (status === "Ausgetreten") return "Terminated";
return "Working";
}
export type CornerstoneZeile = Record<string, string | number>;
/** Die Person, so wie der Export sie braucht. */
export type CornerstoneQuelle = {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
title_prefix: string[];
title_suffix: string[];
gender: string;
company_email: string | null;
cornerstone_id: string | null;
address: string | null;
postal_code: string | null;
city: string | null;
address_country: string | null;
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
austrittsart: string | null;
absence_type: string | null;
status: EmploymentStatus;
paygrade: string;
location_id: string;
position_id: string | null;
manager_id: string | null;
};
export type CornerstoneKontext = {
/** Cornerstone-ID je Mitarbeiterkennung — der Verweis auf die vorgesetzte Person. */
managerKennung: Map<string, string | null>;
/** Kostenstelle je Planstelle zum Stichtag. */
kostenstelle: Map<string, Kostenstelle>;
};
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
// Zwei Kennungen, zwei Herkünfte:
//
// User ID + Username die Cornerstone-ID (employees.cornerstone_id)
// Local System ID die Personalnummer, wie LOGA sie vergibt
//
// Benutzername und Benutzer-ID sind stets derselbe Wert — liefen sie
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
// jeder Verweis darauf träfe womöglich die falsche.
//
// Hier stand die Alpenwerk-UUID, solange es nichts Besseres gab. Seit
// 20260928100000 führt Alpenwerk die Kennung mit, unter der Cornerstone
// die Person selbst kennt; die UUID sagte dort niemandem etwas.
//
// **Leer, wenn keine hinterlegt ist.** Ein Rückfall auf die UUID brächte
// zwei Kennungsarten in eine Datei, ohne dass es jemandem auffiele — und
// legte in Cornerstone eine zweite Person neben der bestehenden an. Eine
// fehlende Kennung ist eine fehlende Angabe und soll als solche auffallen.
//
// Der Anmeldename des Verzeichnisdienstes steht nur im Customfield AD.
const kennung = p.cornerstone_id ?? "";
const nummer = String(p.personnel_number);
const adName = benutzername(p.first_name, p.last_name);
const kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined;
// Der Verweis auf die vorgesetzte Person geht über **deren** User ID, also
// über ihre Cornerstone-ID. Hier stand die Personalnummer: die ist in dieser
// Datei die Local System ID, und Cornerstone hätte den Verweis entweder ins
// Leere laufen lassen oder — schlimmer — auf jemand anderen gelegt, dessen
// User ID zufällig so aussieht wie eine Personalnummer.
//
// Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID trägt. Aus
// demselben Grund wie oben: eine Kennung der falschen Art ist schlimmer als
// keine, weil niemand ihr ansieht, dass sie falsch ist.
const chef = p.manager_id ? k.managerKennung.get(p.manager_id) : undefined;
const ausgetreten = p.status === "Ausgetreten";
return {
"User ID": kennung,
"Local System ID": nummer,
Prefix: p.title_prefix.join(" "),
"First Name": p.first_name,
"Middle Name": "",
"Last Name": p.last_name,
Suffix: p.title_suffix.join(" "),
Username: kennung,
Approver: "",
Manager: chef ?? "",
Absent: "",
"Allow Reconciliation": "",
Email: p.company_email ?? "",
"Required Training Approvals": "",
Gender: geschlecht(p.gender),
Exempt: "",
Ethnicity: "",
// Leer: Cornerstone rechnet die Dienstzeit aus den Eintrittsdaten selbst.
// Ein mitgeladener Wert wäre ab dem Tag nach dem Einlesen veraltet.
"Months of Service": "",
"Compensation Currency": CORNERSTONE_WAEHRUNG,
"Address Line 1": p.address ?? "",
"Address Line 2": "",
City: p.city ?? "",
State: "",
Zip: p.postal_code ?? "",
Mailstop: "",
Country: p.address_country ?? "",
"Original Hire Date": csDatum(p.entry_date),
"Last Hire Date": csDatum(p.entry_date),
Phone: "",
Mobile: "",
// Private Nummer und private Adresse bleiben im Haus — siehe oben.
"Home Phone": "",
Fax: "",
Status: kontoStatus(p.status),
"User activation date": "",
"User de-activation date": "",
"Personal Email": "",
Language: CORNERSTONE_SPRACHE,
"Time Zone": CORNERSTONE_ZEITZONE,
"User Type": CORNERSTONE_USER_TYPE,
"Employment Status": beschaeftigungsStatus(p.status),
// Die vier Austrittsfelder und der Abwesenheitsgrund nur, wenn der
// Beschäftigungsstatus sie trägt. Ein Grund ohne Austritt ist für den
// Load ein ungültiger Zustand, nicht eine Zusatzangabe.
"Leave Reason": ausgetreten ? "" : p.status === "Karenz" ? (p.absence_type ?? "") : "",
"Termination Type": ausgetreten ? (p.austrittsart ?? "") : "",
"Termination Reason": ausgetreten ? (p.exit_reason ?? "") : "",
"Termination Date": ausgetreten ? csDatum(p.exit_date) : "",
// Wiedereinstellbarkeit wird in Alpenwerk nicht erhoben.
"Eligible For Rehire": "",
// „Nein" gehört laut Vorgabe nicht in die Datei, und der Wert für „Ja"
// ist nicht bestätigt. Ein nicht bestätigter Wert liesse den Load die
// **ganze** Zeile abweisen, nicht nur das Feld — deshalb bleibt sie leer.
"Rehired Employee": "",
"Division ID": DIVISION_GUID,
// Leer: die Planstellennummer aus Alpenwerk (S-0001) ist keine
// Cornerstone-Position-ID. Sie hier einzutragen hiesse, eine Kennung des
// Zielsystems zu erfinden.
"Position ID": "",
// Der Hay-Grade, so wie er gespeichert ist. Wer keinen trägt, bekommt den
// „Generic Grade" — und der ist laut der Tabelle des Kunden der
// Bindestrich, nicht die leere Zelle. Hier wird deshalb nichts übersetzt;
// siehe lib/hay-grade.ts.
"Grade ID": p.paygrade,
"Cost Center ID": kst?.code ?? "",
// Leer: Cornerstone erwartet seine eigene Standortkennung („01"), und
// die Tabelle locations führt nur id, name und country. Der Name wäre
// hier keine Kennung, sondern eine, die es im Zielsystem nicht gibt.
// Solange dafür keine Spalte existiert, bleibt das Feld leer.
"Location ID": "",
// Beide Spalten wertet der Load derzeit nicht aus. Sie stehen in der
// Kopfzeile, weil die Vorlage sie führt.
"Retention Rules": "",
Organisationsstufe: "",
"Customfield ID AD": adName,
"Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`,
"Customfield ID Interflex": String(nummer),
"Customfield ID LGVplus": `${LGV_PRAEFIX}${nummer}`,
};
}
/** Die Spalten in der Reihenfolge, die Cornerstone erwartet. */
export const CORNERSTONE_SPALTEN: readonly string[] = [
"User ID",
"Local System ID",
"Prefix",
"First Name",
"Middle Name",
"Last Name",
"Suffix",
"Username",
"Approver",
"Manager",
"Absent",
"Allow Reconciliation",
"Email",
"Required Training Approvals",
"Gender",
"Exempt",
"Ethnicity",
"Months of Service",
"Compensation Currency",
"Address Line 1",
"Address Line 2",
"City",
"State",
"Zip",
"Mailstop",
"Country",
"Original Hire Date",
"Last Hire Date",
"Phone",
"Mobile",
"Home Phone",
"Fax",
"Status",
"User activation date",
"User de-activation date",
"Personal Email",
"Language",
"Time Zone",
"User Type",
"Employment Status",
"Leave Reason",
"Termination Type",
"Termination Reason",
"Termination Date",
"Eligible For Rehire",
"Rehired Employee",
"Division ID",
"Position ID",
"Grade ID",
"Cost Center ID",
"Location ID",
"Retention Rules",
"Organisationsstufe",
"Customfield ID AD",
"Customfield ID Doxis",
"Customfield ID Interflex",
"Customfield ID LGVplus",
];
export function cornerstoneSpalten(): ExportColumn<CornerstoneZeile>[] {
return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
}

View File

@@ -22,13 +22,22 @@ export type DashboardParams = {
userId: string | null;
today: string;
yearStart: string;
yearEnd: string;
/**
* Das obere Ende des YTD-Fensters — **heute**, nicht der 31.12.
*
* Hier stand das Jahresende, womit die Kacheln „Eintritte/Austritte (YTD)"
* auch zählten, was erst bevorsteht: ein für den 01.11. erfasster Austritt
* stand schon im September als geschehen da. „Year to date" heisst bis
* heute, und die Kachel soll sagen, was war — was kommt, steht in
* „Anstehend" daneben. Gemeldet im Test vom 29.09. (B.02).
*/
ytdBis: string;
bisIso: string;
arten: AnstehendArt[];
};
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
const { userId, today, yearStart, ytdBis, bisIso, arten } = p;
const zeigt = (art: AnstehendArt) => arten.includes(art);
// Elf Abfragen standen hier in einem Promise.all, das keine
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
@@ -43,7 +52,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
.where("event_type", "in", [...types])
.where("event_date", ">=", yearStart)
.where("event_date", "<=", yearEnd);
.where("event_date", "<=", ytdBis);
const g = await tx
.selectNoFrom((eb) => [

View File

@@ -10,8 +10,13 @@ import { offeneStellenAbfrage, resolveOpenPositions, type OffeneStelle } from ".
//
// Vorher: die Person, dann acht Dinge in einem Promise.all, dann die Namen der
// Beteiligten. Das Promise.all war keine Gleichzeitigkeit — eine Transaktion
// hängt an einer Verbindung (lib/db/json.ts). Bei rund 36 ms Umlaufzeit war
// diese Seite damit die teuerste der Anwendung.
// hängt an einer Verbindung (lib/db/json.ts).
//
// Hier stand „bei rund 36 ms Umlaufzeit". Nachgemessen sind es 0,5 ms; die
// Zahl der Rundreisen ist also nicht der Hebel, für den sie hier gehalten
// wurde. Teuer war die Akte aus einem anderen Grund — 1,5 s, davon 1,4 s in
// om_reporting_lines unter Zeilenschutz (siehe Migration 20260924220000).
// Jetzt liegt die ganze Seite bei rund 100 ms.
//
// Die drei Tabellen mit `selectAll()` stehen hier ausgeschrieben. Das ist
// keine Umständlichkeit: innerhalb von JSON formatiert Postgres timestamptz
@@ -27,16 +32,27 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
// behauptete „Keine Führungskraft", obwohl das Team eine hat.
const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst();
if (!employee) return null;
const asOf = orgAsOf(employee, today);
const g = await tx
.selectNoFrom((eb) => [
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
// kommen aus om_reporting_lines(). Beide schränken *in* der Funktion
// ein, es wandern also neun Zeilen über die Leitung und nicht achthundert.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { employeeId: id })).as("ownLines"),
jsonArrayFrom(berichtslinienAbfrage(eb, asOf, { actingManagerId: id })).as("reports"),
// kommen aus om_reporting_lines().
//
// **Einmal** gerufen, nicht zweimal. Hier standen zwei Teilabfragen mit
// je einem Filter, in der Annahme, der Filter schränke die Funktion
// ein. Das tut er nicht: wegen des `with recursive` in ihrem Körper
// lässt sie sich nicht einbetten, sie rechnet jedes Mal alle Zeilen aus
// und der Filter wirft sie danach weg. Auf dem Server gemessen:
//
// explain analyze select * from om_reporting_lines(current_date)
// where employee_id = …;
// → Rows Removed by Filter: 783, Execution Time: 69 ms
//
// Zwei Filter hiessen also zweimal dieselbe Rechnung. Jetzt wandern
// einmal alle Zeilen herüber — ein paar hundert Kilobyte über eine
// Verbindung im selben Netz — und die Auswahl trifft der Aufrufer.
jsonArrayFrom(berichtslinienAbfrage(eb, asOf)).as("lines"),
jsonArrayFrom(
eb
@@ -110,14 +126,15 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
.executeTakeFirstOrThrow();
const orgMaps = buildOrgMaps(g.units as never, g.locations as never);
const line = g.ownLines[0] ?? null;
const line = g.lines.find((l) => l.employee_id === id) ?? null;
const reports = g.lines.filter((l) => l.acting_manager_id === id);
const placement = pickPlacements(g.placementRows as never, asOf).get(id) ?? null;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...g.reports.map((r) => r.employee_id)].filter(
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
@@ -137,7 +154,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
return {
employee,
line,
reports: g.reports,
reports,
history: g.history,
dependents: g.dependents,
notes: g.notes,

View File

@@ -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.

View File

@@ -10,13 +10,14 @@ import type { Schema } from "@/lib/db/schema";
//
// Bedient wird sie über die Spaltenköpfe. Damit das keine leere Zusage ist,
// sortiert **jede** dieser Spalten über den gesamten Bestand, auch die drei,
// die nicht auf der Person stehen: Bereich/Team hängt an der Planstelle,
// Standort an einer Nachschlagetabelle, und der Status ist eine Aufzählung.
// die nicht auf der Person stehen: die Organisationseinheit hängt an der
// Planstelle, der Standort an einer Nachschlagetabelle, und der Status ist
// eine Aufzählung.
export const SORTIERFELDER = [
{ value: "name", label: "Mitarbeiter:in" },
{ value: "persnr", label: "Pers.-Nr." },
{ value: "bereich", label: "Bereich/Team" },
{ value: "einheit", label: "Organisationseinheit" },
{ value: "standort", label: "Standort" },
{ value: "eintritt", label: "Eintritt" },
{ value: "beschaeftigung", label: "Beschäftigung" },
@@ -72,25 +73,13 @@ const einheitAusdruck = sql<string>`(
where pa.employee_id = employees.id and pa.valid_to is null
limit 1)`;
/**
* Der Bereich darüber — die erste Ebene unter der Gesellschaft.
*
* Ohne Rekursion: der Baum hat höchstens vier Ebenen (Gesellschaft, Bereich,
* Abteilung, Team), also genügen zwei Sprünge nach oben. Sitzt jemand direkt
* am Bereich, greift schon der erste Fall.
*/
const bereichAusdruck = sql<string>`(
select coalesce(
case when ou.unit_type = 'Bereich' then ou.name end,
case when e1.unit_type = 'Bereich' then e1.name end,
case when e2.unit_type = 'Bereich' then e2.name end)
from position_assignments pa
join om_positions p on p.id = pa.position_id
join org_units ou on ou.id = p.org_unit_id
left join org_units e1 on e1.id = ou.parent_id
left join org_units e2 on e2.id = e1.parent_id
where pa.employee_id = employees.id and pa.valid_to is null
limit 1)`;
// Hier stand ein zweiter Ausdruck, der den Bereich über der Person suchte:
// die Liste sortierte nach Bereich und erst darin nach der Einheit. Der
// Bereich steht seit 22.09.2026 nicht mehr in der Spalte — der Kunde wollte
// dort nur die eigene Einheit sehen, weil die Bereiche bei ihm CEO, CFO, COO
// heissen und als Zeile über jedem Namen nichts beitragen. Sortiert wird
// seither nach dem, was auch dasteht. Eine Sortierung nach einem Wert, den
// die Spalte nicht zeigt, sieht von aussen aus wie gar keine Sortierung.
const standortAusdruck = sql<string>`(select name from locations where id = employees.location_id)`;
@@ -155,8 +144,8 @@ export function sortiere<O>(
switch (feld) {
case "persnr":
return q.orderBy(ordne(sql.ref("personnel_number")));
case "bereich":
return nachName(q.orderBy(ordne(bereichAusdruck)).orderBy(ordne(einheitAusdruck)));
case "einheit":
return nachName(q.orderBy(ordne(einheitAusdruck)));
case "standort":
return nachName(q.orderBy(ordne(standortAusdruck)));
case "eintritt":

134
lib/export-auswahl.ts Normal file
View File

@@ -0,0 +1,134 @@
import type { Tx } from "./db";
import { derivedStatusFilter } from "./employee-status-filter";
import { fmtName, todayIso } from "./format";
import { subtreeOf, type OrgMaps } from "./org";
import { loadPlacements, loadReportingLineMap } from "./placement";
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "./report-criteria";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "./reports";
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "./reports-data";
import type { Database } from "./types";
// Wer in einem Personenexport steht — für alle Exporte dieselbe Antwort.
//
// Stand bis September 2026 in der Route des vollständigen Exports. Seit es
// den Honestly-Export daneben gibt, gäbe es sonst zwei Fassungen derselben
// Auswahl, und die beiden Dateien enthielten bei gleichen Filtern
// verschiedene Leute, ohne dass es jemand merkt.
/** Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
* über die Planstelle und die abgeleitete Berichtslinie. */
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
position_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
};
export type ExportParameter = {
format: "csv" | "xlsx";
asOf: string | undefined;
filters: ReportFilters;
};
export function exportParameter(params: URLSearchParams): ExportParameter {
return {
format: params.get("format") === "xlsx" ? "xlsx" : "csv",
asOf: parseIsoDateParam(params.get("asOf")),
filters: {
division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined,
status: params.get("status") ?? undefined,
criteria: parseCriteria((k) => params.get(k)),
},
};
}
/**
* Die Personen, die ein Export mit diesen Filtern enthält, samt Einordnung.
*
* Mit Stichtag wird der Status gegen den *abgeleiteten* Stand an diesem Tag
* geprüft, nicht gegen die Spalte `status` — siehe deriveStatusAsOf.
*/
export async function ladeExportMitarbeiter(
tx: Tx,
{ asOf, filters }: Pick<ExportParameter, "asOf" | "filters">
): Promise<{
rows: ExportMitarbeiter[];
lookups: OrgLookups;
orgMaps: OrgMaps;
managerName: Map<string, string>;
/** Cornerstone-ID je Kennung — für Exporte, die über die Kennung des Zielsystems verweisen. */
managerKennung: Map<string, string | null>;
dependentsCounts: Map<string, number>;
}> {
const statuses = parseStatuses(filters.status);
const stichtag = asOf ?? todayIso();
const criteria = filters.criteria ?? LEERE_CRITERIA;
function employeeQuery() {
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
if (filters.location) q = q.where("location_id", "=", filters.location);
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
// „Ausgetreten" liess damit genau die Leute aus, die gerade
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
// und einmal im Speicher.
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
return applyCriteria(q, criteria);
}
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
employeeQuery().execute(),
loadOrgLookups(tx),
// Absichtlich schmal: die Liste umfasst **alle** Personen, nicht nur die
// gefilterten, und dient nur dazu, einen Verweis aufzulösen.
tx.selectFrom("employees").select(["id", "first_name", "last_name", "cornerstone_id"]).orderBy("id").execute(),
loadDependentsCounts(tx),
loadPlacements(tx, { asOf: stichtag }),
loadReportingLineMap(tx, stichtag),
]);
const orgMaps = lookupResult.orgMaps;
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const enriched: ExportMitarbeiter[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
return [
{
...e,
// Die Tätigkeit der Planstelle, auf der die Person am Stichtag sitzt —
// und nur ersatzweise die Spalte an der Person. Genau so löst die
// Berichtsseite es auf (loadSnapshotEmployees); vorher stand hier die
// Spalte allein, und damit konnte in der Akte eine andere Position
// stehen als in der Datei daneben, ohne dass eines von beidem falsch
// aussah.
job_title: placement?.current ? placement.jobTitle : e.job_title,
org_unit_id: orgUnitId,
position_id: placement?.current ? placement.positionId : null,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
},
];
});
return {
rows: asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched,
lookups: lookupResult.lookups,
orgMaps,
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
managerKennung: new Map(allEmployees.map((e) => [e.id, e.cornerstone_id])),
dependentsCounts,
};
}

View File

@@ -23,20 +23,41 @@ export function sanitizeForSpreadsheetCell(text: string): string {
return /^[=+\-@]/.test(text) ? `'${text}` : text;
}
function csvCell(value: string | number | boolean | null): string {
function csvCell(value: string | number | boolean | null, trenner: string, formelschutz: boolean): string {
if (value === null || value === undefined) return "";
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
const roh = typeof value === "boolean" ? (value ? "Ja" : "Nein") : String(value);
const text = formelschutz ? sanitizeForSpreadsheetCell(roh) : roh;
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
// makes umlauts render correctly instead of mojibake on open.
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
/**
* Wie die Datei aussehen soll.
*
* Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
* Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
*
* `formelschutz: false` schaltet das vorangestellte Hochkomma ab (siehe
* sanitizeForSpreadsheetCell). Es gehört in eine Datei, die ein Mensch in Excel
* öffnet, und es verdirbt eine, die eine Maschine einliest: eine österreichische
* Telefonnummer beginnt mit „+", und im Zielsystem stünde danach `'+43…` als
* Nummer. Nur für Dateien setzen, die ausschliesslich ein Fremdsystem liest.
*/
export type CsvForm = { trenner?: "," | ";"; bom?: boolean; formelschutz?: boolean };
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
const trenner = form.trenner ?? ";";
const bom = form.bom ?? true;
const schutz = form.formelschutz ?? true;
const lines = [columns.map((c) => csvCell(c.header, trenner, schutz)).join(trenner)];
for (const row of rows) {
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
lines.push(columns.map((c) => csvCell(c.get(row), trenner, schutz)).join(trenner));
}
return "" + lines.join("\r\n");
return (bom ? "" : "") + lines.join("\r\n");
}
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel

55
lib/hay-grade.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { PaygradeType } from "./types";
// Die Hay-Grades — die Bewertungsstufen, die der Kunde tatsächlich führt.
//
// Bis September 2026 stand an dieser Stelle eine Verwendungsgruppe A–F samt
// Beschreibungen („B – Qualifiziert"). Die stammte aus der Spezifikation, nicht
// vom Kunden: sechs erfundene Stufen. Dieselbe Spalte füllt im
// Cornerstone-Extrakt die „Grade ID", und dort ist A–F keine Kennung, die das
// Zielsystem kennt — die Datei wäre in jeder Zeile falsch gewesen, ohne dass
// es beim Erzeugen aufgefallen wäre.
//
// Gespeichert wird die ID, angezeigt der Titel. Bei den HG-Stufen ist beides
// dasselbe; nur „Generic Grade" trägt als ID einen Bindestrich. Der steht so
// in der Tabelle des Kunden und wird hier nicht in eine leere Angabe
// übersetzt: ein Bindestrich ist dort ein Wert, kein fehlender.
//
// Dieselbe Liste steht in Migration 20260928140000 als CHECK der Domain
// paygrade_type; tests/unit/hay-grade.test.ts hält beide gegeneinander.
export const HAY_GRADES: readonly { value: PaygradeType; label: string }[] = [
{ value: "-", label: "Generic Grade" },
{ value: "HG09", label: "HG09" },
{ value: "HG10", label: "HG10" },
{ value: "HG11", label: "HG11" },
{ value: "HG12", label: "HG12" },
{ value: "HG13", label: "HG13" },
{ value: "HG14", label: "HG14" },
{ value: "HG15", label: "HG15" },
{ value: "HG16", label: "HG16" },
{ value: "HG17", label: "HG17" },
{ value: "HG18", label: "HG18" },
{ value: "HG19", label: "HG19" },
{ value: "HG19P", label: "HG19P" },
{ value: "HG20", label: "HG20" },
] as const;
export const HAY_GRADE_WERTE: readonly PaygradeType[] = HAY_GRADES.map((g) => g.value);
/**
* Die Vorgabe — und zugleich der Wert, den der Bestand bei der Umstellung
* bekommen hat.
*
* Aus A–F liesse sich kein Hay-Grade ableiten: die alten Stufen waren eine
* andere Einteilung mit einer anderen Anzahl. Jede Zuordnung wäre geraten,
* und geraten sähe im Extrakt genauso aus wie erhoben.
*/
export const HAY_GRADE_STANDARD: PaygradeType = "-";
/** Der anzuzeigende Titel; unbekannte Werte bleiben, wie sie in der Zeile stehen. */
export function hayGradeLabel(wert: string | null | undefined): string {
return HAY_GRADES.find((g) => g.value === wert)?.label ?? wert ?? "–";
}
export function istHayGrade(wert: string | null | undefined): wert is PaygradeType {
return HAY_GRADE_WERTE.includes(wert as PaygradeType);
}

46
lib/historie-felder.ts Normal file
View File

@@ -0,0 +1,46 @@
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";
// Welche Felder in „Eintrag berichtigen" eine Auswahl statt eines Textfeldes
// bekommen.
//
// Der Dialog zeigte für jedes Feld dasselbe freie Textfeld — auch für
// „Notfallkontakt Verhältnis", wo die Erfassung überall sonst eine Liste
// führt. Wer dort „Gattin" statt „Gattin/Gatte" eintippte, erzeugte einen
// Wert, den keine Auswertung mehr findet, und merkte nichts davon: die
// Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen
// Prüfung.
//
// Die Schlüssel sind die Bezeichnungen aus `app_feld_karte()` — genau die
// Zeichenketten, die in `employee_history.changes` unter `feld` stehen. Ein
// Tippfehler hier fiele nicht auf, das Feld bliebe einfach ein Textfeld;
// deshalb prüft tests/unit/historie-felder.test.ts jeden Schlüssel gegen die
// Migration.
//
// Bewusst **nicht** aufgenommen sind die Felder mit Aufzählungstypen in der
// Datenbank (Geschlecht, Beschäftigungsausmaß, Vertragsart, Besetzungsart …)
// und die Ja/Nein-Felder. Dort ist der gespeicherte Text nicht die Anzeige —
// `m`/`w`, `true`/`false` —, und eine Liste, die die Anzeigeform schreibt,
// liefe in einen Umwandlungsfehler oder, schlimmer, in einen stillen
// 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,
"Art der Abwesenheit": ABSENCE_TYPES,
};
/** Die Auswahl zu einem Feld, oder nichts — dann bleibt es ein Textfeld. */
export function auswahlFuer(feld: string): readonly string[] | undefined {
return FELD_AUSWAHLEN[feld];
}

104
lib/honestly.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { ExportColumn } from "./export";
import { ancestorsOf, type OrgMaps } from "./org";
// Der Export für Honestly (https://www.honestly.de/), die Plattform für
// Mitarbeiterbefragungen: eine Zeile je Person, die befragt werden soll.
//
// Zwei Spalten stehen fest, statt aus den Daten zu kommen — so ist es
// vorgegeben: die Sprache ist immer Deutsch, die Rolle immer „Respondee".
// Wer anders befragt werden soll, wird in Honestly umgestellt, nicht hier.
//
// Der Organisationsbaum kommt von unten nach oben: „OU" ist die Einheit der
// Person, „OU+1" die darüber, und so weiter bis zur obersten. Die Zahl dieser
// Spalten richtet sich nach der tiefsten Kette im Export. Wer weiter oben
// hängt, hat hinten leere Zellen — und die oberste Einheit steht deshalb
// nicht bei allen in derselben Spalte. So ist es bestellt.
// Die E-Mail-Spalte trägt die **Firmenadresse** (employees.company_email,
// Migration 20260923120000) und niemals die private aus employees.email: die
// Datei geht an einen fremden Anbieter, der damit im Namen des Arbeitgebers
// einlädt. Eine Privatadresse hat dort nichts verloren.
//
// Bis zur Migration war die Spalte ein leerer Platzhalter — sie stand
// trotzdem in der Datei, damit die Zuordnung in Honestly nicht neu
// eingerichtet werden muss. Wer keine Firmenadresse hinterlegt hat, hat sie
// weiterhin leer; das ist eine fehlende Angabe und keine Ausrede, die private
// zu nehmen.
export const HONESTLY_SPRACHE = "de";
export const HONESTLY_ROLLE = "Respondee";
export type HonestlyZeile = {
personalnummer: number | string | null;
/** Die dienstliche Adresse. Leer, wenn keine hinterlegt ist. */
email: string;
vorname: string;
nachname: string;
standort: string;
/** Die Einheit der Person zuerst, die oberste zuletzt. */
kette: string[];
};
/**
* Die Namen der Einheiten von der eigenen bis zur obersten.
*
* ancestorsOf liefert die Wurzel zuerst; hier wird es umgedreht, weil die
* Spalten bei der eigenen Einheit anfangen.
*/
export function orgKette(maps: OrgMaps, unitId: string | null | undefined): string[] {
return ancestorsOf(maps, unitId)
.map((u) => u.name)
.reverse();
}
/** Überschrift der n-ten Organisationsspalte: OU, OU+1, OU+2, … */
export function ouUeberschrift(stufe: number): string {
return stufe === 0 ? "OU" : `OU+${stufe}`;
}
/**
* Die Spalten in der Reihenfolge der Vorgabe.
*
* Mindestens eine OU-Spalte, auch wenn niemand im Export einer Einheit
* zugeordnet ist: eine Datei, deren Spalten je nach Inhalt verschwinden,
* bricht jede Zuordnung, die auf der anderen Seite eingerichtet wurde.
*/
export function honestlySpalten(tiefe: number): ExportColumn<HonestlyZeile>[] {
const ou: ExportColumn<HonestlyZeile>[] = Array.from({ length: Math.max(1, tiefe) }, (_, stufe) => ({
header: ouUeberschrift(stufe),
get: (z) => z.kette[stufe] ?? "",
}));
return [
{ header: "Personalnummer", get: (z) => z.personalnummer },
{ header: "Email", get: (z) => z.email },
{ header: "Firstname", get: (z) => z.vorname },
{ header: "Last Name", get: (z) => z.nachname },
{ header: "Language", get: () => HONESTLY_SPRACHE },
{ header: "Location", get: (z) => z.standort },
...ou,
{ header: "Role", get: () => HONESTLY_ROLLE },
];
}
/** Aus den Exportzeilen die Honestly-Zeilen und die Zahl der OU-Spalten. */
export function baueHonestlyZeilen(
personen: {
personnel_number: number | string | null;
company_email: string | null;
first_name: string;
last_name: string;
location_id: string;
org_unit_id: string | null;
}[],
maps: OrgMaps,
standortName: Map<string, string>
): { zeilen: HonestlyZeile[]; tiefe: number } {
const zeilen = personen.map((p) => ({
personalnummer: p.personnel_number,
email: p.company_email ?? "",
vorname: p.first_name,
nachname: p.last_name,
standort: standortName.get(p.location_id) ?? "",
kette: orgKette(maps, p.org_unit_id),
}));
return { zeilen, tiefe: Math.max(1, ...zeilen.map((z) => z.kette.length)) };
}

View File

@@ -1,6 +1,7 @@
import "server-only";
import { sql, type Tx } from "@/lib/db";
import { todayIso } from "@/lib/format";
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
import { deriveStatusAsOf } from "@/lib/reports";
import { normalizeSvnr } from "@/lib/svnr";
import { sortiereWochentage } from "@/lib/wochentage";
@@ -58,7 +59,7 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
.select(["position_id"])
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
.execute(),
tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(),
tx.selectFrom("employees").select(["id", "personnel_number", "email", "company_email", "cornerstone_id", "sv_nummer"]).execute(),
]);
const besetzt = new Set(besetzungen.map((b) => b.position_id));
@@ -72,6 +73,8 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
firmenEmails: new Set(personen.filter((e) => e.company_email).map((e) => e.company_email!.toLowerCase())),
cornerstoneIds: new Set(personen.filter((e) => e.cornerstone_id).map((e) => e.cornerstone_id!)),
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
};
}
@@ -209,6 +212,8 @@ export async function laden(
city: txt(w.city),
address_country: txt(w.address_country),
email: txt(w.email)!,
company_email: txt(w.company_email),
cornerstone_id: txt(w.cornerstone_id),
phone: txt(w.phone),
title_prefix: liste(w.title_prefix) ?? [],
title_suffix: liste(w.title_suffix) ?? [],
@@ -225,7 +230,7 @@ export async function laden(
work_days: sortiereWochentage(liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
contract_end_date: txt(w.contract_end_date),
paygrade: (txt(w.paygrade) ?? "B") as never,
paygrade: (txt(w.paygrade) ?? HAY_GRADE_STANDARD) as never,
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
monthly_salary_gross: zahl(w.monthly_salary_gross),

View File

@@ -1,4 +1,5 @@
import { ABSENCE_TYPES } from "@/lib/absence";
import { HAY_GRADE_WERTE } from "@/lib/hay-grade";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
// Was in einer Importdatei stehen darf.
@@ -232,6 +233,22 @@ export const BLATT_PERSONEN: BlattSchema = {
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
beispiel: "s.aigner@example.at",
},
{
name: "Firmen-E-Mail",
ziel: "company_email",
pflicht: false,
typ: { art: "text" },
hinweis: "Dienstliche Adresse. Freiwillig, eindeutig. Geht in den Export für die Mitarbeiterbefragung.",
beispiel: "s.aigner@manner.com",
},
{
name: "Cornerstone-ID",
ziel: "cornerstone_id",
pflicht: false,
typ: { art: "text" },
hinweis: "Kennung im Lernsystem Cornerstone. Freiwillig, eindeutig. Füllt dort „User ID“ und „Username“.",
beispiel: "10000382",
},
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
@@ -323,12 +340,12 @@ export const BLATT_PERSONEN: BlattSchema = {
beispiel: "",
},
{
name: "Verwendungsgruppe",
name: "Hay-Grade",
ziel: "paygrade",
pflicht: false,
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
hinweis: "Leer = B.",
beispiel: "C",
typ: { art: "auswahl", werte: [...HAY_GRADE_WERTE] },
hinweis: "Leer = Generic Grade, also ein Bindestrich.",
beispiel: "HG13",
},
{
name: "Kollektivvertrag",

View File

@@ -34,6 +34,8 @@ export type Bestand = {
planstellen: Map<string, { id: string; besetzt: boolean }>;
personalnummern: Map<number, string>;
emails: Set<string>;
firmenEmails: Set<string>;
cornerstoneIds: Set<string>;
svNummern: Set<string>;
};
@@ -44,6 +46,8 @@ export const LEERER_BESTAND: Bestand = {
planstellen: new Map(),
personalnummern: new Map(),
emails: new Set(),
firmenEmails: new Set(),
cornerstoneIds: new Set(),
svNummern: new Set(),
};
@@ -262,6 +266,8 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
const heute = todayIso();
const personalnummern = new Set([...bestand.personalnummern.keys()]);
const emails = new Set([...bestand.emails]);
const firmenEmails = new Set([...bestand.firmenEmails]);
const cornerstoneIds = new Set([...bestand.cornerstoneIds]);
const svNummern = new Set([...bestand.svNummern]);
const belegtePlanstellen = new Map<string, number>();
const eintritte = new Map<number, string>();
@@ -282,6 +288,29 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
}
// Eigener Vorrat, nicht derselbe wie bei der privaten Adresse: die beiden
// Spalten haben je einen eigenen Index, und dieselbe Adresse darf bei
// einer Person privat und dienstlich stehen.
const firmenEmail = s(w.company_email)?.toLowerCase();
if (firmenEmail) {
if (firmenEmails.has(firmenEmail)) melde("Personen", z.zeile, "Firmen-E-Mail", "Kommt bereits vor.", firmenEmail);
firmenEmails.add(firmenEmail);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(firmenEmail)) {
melde("Personen", z.zeile, "Firmen-E-Mail", "Sieht nicht wie eine Adresse aus.", firmenEmail);
}
}
// Die Kennung gehört genau einer Person; die Spalte trägt einen
// eindeutigen Index. Ohne Prüfung hier bräche erst das Schreiben ab —
// mitten im Import und ohne Zeilennummer.
const cornerstoneId = s(w.cornerstone_id);
if (cornerstoneId) {
if (cornerstoneIds.has(cornerstoneId)) {
melde("Personen", z.zeile, "Cornerstone-ID", "Kommt bereits vor.", cornerstoneId);
}
cornerstoneIds.add(cornerstoneId);
}
const geburt = s(w.birth_date);
const svRoh = s(w.sv_nummer);
if (svRoh) {

158
lib/lolyo.ts Normal file
View File

@@ -0,0 +1,158 @@
import type { CsvForm, ExportColumn } from "./export";
// Der Export für LOLYO, die Mitarbeiter-App: eine Zeile je Person, die dort
// einen Zugang bekommen soll.
//
// Diese Datei tut etwas anderes als die beiden Exporte daneben. Honestly lädt
// zu einer Befragung ein, Cornerstone führt Stammdaten nach — hier entstehen
// **Konten**, samt Passwort in der Zeile. Wer die Datei hat, hat damit einen
// Zugang zu jedem Konto darin, solange niemand sein Passwort gewechselt hat.
// Deshalb steht das Passwort nicht in dieser Datei: die Route reicht es herein
// (app/api/export/lolyo/route.ts), und es gelangt damit nie in ein Bündel, das
// der Browser lädt. Dieselbe Überlegung steht in lib/passwort.ts.
//
// Kopfzeile und Trennzeichen kommen aus der Vorlage des Anbieters, Zeichen für
// Zeichen. Die gemischte Schreibweise — „Titel prefix" deutsch, „Title Suffix"
// englisch — steht so darin; sie hier stillschweigend zu vereinheitlichen
// hiesse, eine Spalte zu liefern, die LOLYO nicht zuordnet.
/**
* Semikolon, kein BOM, kein Formelschutz — so liest LOLYO die Datei.
*
* Die Vorgabe von toCsv ist für Excel gemacht. Das BOM hinge unsichtbar am
* Namen der ersten Spalte, und „Benutzer/Code" käme beim Einlesen in keiner
* Zuordnung mehr vor.
*
* Der Formelschutz stellt jedem Wert, der mit `=`, `+`, `-` oder `@` beginnt,
* ein Hochkomma voran, damit Excel ihn nicht als Formel rechnet. Hier wäre das
* falsch: **jede** österreichische Telefonnummer beginnt mit „+", und in LOLYO
* stünde danach `'+4366…` als Nummer — in jedem Konto, und niemandem fiele es
* beim Erzeugen auf. Die Datei liest eine Maschine, kein Mensch in Excel.
*/
export const LOLYO_CSV: CsvForm = { trenner: ";", bom: false, formelschutz: false };
/** Für alle dieselbe. Wer eine andere braucht, stellt sie in LOLYO um, nicht hier. */
export const LOLYO_SPRACHE = "DE";
/**
* Die Gruppe, in die alle kommen.
*
* Fest vorgegeben, nicht aus der Organisationseinheit abgeleitet: LOLYO-Gruppen
* sind Verteiler in der App und nicht die Aufbauorganisation. Beides
* gleichzusetzen erzeugte 78 Gruppen, die dort niemand bestellt hat.
*/
export const LOLYO_GRUPPE = "Demo-Gruppe";
/** LOLYO holt die Bestätigung selbst ein; aus Alpenwerk kommt sie nie bestätigt. */
export const LOLYO_EMAIL_BESTAETIGT = "0";
/**
* Die Anrede aus dem Geschlecht.
*
* Alpenwerk kennt genau zwei Werte (gender_type), und die Vorgabe nennt genau
* zwei Anreden. Ein dritter Wert bliebe hier leer statt geraten — eine falsche
* Anrede steht in jeder Nachricht, die die App verschickt.
*/
export function anrede(gender: string): string {
if (gender === "m") return "Herr";
if (gender === "w") return "Frau";
return "";
}
/**
* Typografische Zeichen auf ihre ASCII-Entsprechung.
*
* Im Zielsystem kam der Gedankenstrich „–" als `–` an — das sind seine
* UTF-8-Bytes, gelesen als Windows-1252. Gemessen hat die Vorlage des Anbieters
* dieselbe Kodierung wie unsere Datei (UTF-8 ohne BOM, nachgeprüft am
* „bestätigt" in ihrer Kopfzeile), die Ursache liegt also nicht in dem, was wir
* schreiben. Solange sie nicht geklärt ist, geht der Weg über das Zeichen
* selbst: ein Bindestrich sagt dasselbe und überlebt jede Fehlinterpretation,
* weil er in beiden Kodierungen dasselbe Byte ist.
*
* Umlaute bleiben. „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu
* verstecken, das es entweder gar nicht gibt — oder das dann auch Namen
* betrifft, und dort wäre es keine Kosmetik mehr.
*/
export function ohneTypografie(text: string): string {
return text
.replace(/[‐-―−]/g, "-")
.replace(/[‘’‚‛′]/g, "'")
.replace(/[“”„‟″]/g, '"')
.replace(/…/g, "...")
.replace(/[   ]/g, " ");
}
/** Die Person, so wie der Export sie braucht. */
export type LolyoQuelle = {
id: string;
company_email: string | null;
title_prefix: string[];
title_suffix: string[];
first_name: string;
last_name: string;
job_title: string;
phone: string | null;
gender: string;
};
export type LolyoZeile = Record<string, string>;
/**
* Eine Zeile.
*
* Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
* hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
* Kennung, die wir treffen müssten — die UUID ist der Schlüssel, über den sich
* ein späterer Abgleich wieder auf dieselbe Person bezieht.
*
* Die E-Mail-Spalte trägt die **Firmenadresse** und niemals die private aus
* employees.email: die Datei legt Konten bei einem fremden Anbieter an.
*/
export function baueLolyoZeile(p: LolyoQuelle, passwort: string): LolyoZeile {
// ohneTypografie steht an jeder Zelle, die aus den Daten kommt, und nicht nur
// an der Position: aufgefallen ist es dort, weil die Tätigkeiten einen
// Gedankenstrich führen — dieselben Zeichen stehen aber auch in Namen und
// Titeln, und die fielen erst bei der Person auf, die einen trägt.
const aus = (wert: string) => ohneTypografie(wert);
return {
"Benutzer/Code": p.id,
Email: aus(p.company_email ?? ""),
// **Ohne** Umschrift: das Passwort muss Zeichen für Zeichen das sein, was
// die Person eingibt. Ein ersetztes Zeichen fiele niemandem auf — es gäbe
// nur ein Konto, in das niemand hineinkommt.
Passwort: passwort,
"Titel prefix": aus(p.title_prefix.join(" ")),
Vorname: aus(p.first_name),
Nachname: aus(p.last_name),
"Title Suffix": aus(p.title_suffix.join(" ")),
Position: aus(p.job_title),
Gruppen: LOLYO_GRUPPE,
Telefon: aus(p.phone ?? ""),
Anrede: anrede(p.gender),
Sprache: LOLYO_SPRACHE,
"E-Mail bestätigt": LOLYO_EMAIL_BESTAETIGT,
};
}
/** Die Spalten in der Reihenfolge der Vorlage. */
export const LOLYO_SPALTEN: readonly string[] = [
"Benutzer/Code",
"Email",
"Passwort",
"Titel prefix",
"Vorname",
"Nachname",
"Title Suffix",
"Position",
"Gruppen",
"Telefon",
"Anrede",
"Sprache",
"E-Mail bestätigt",
];
export function lolyoSpalten(): ExportColumn<LolyoZeile>[] {
return LOLYO_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
}

46
lib/org-nummer.ts Normal file
View File

@@ -0,0 +1,46 @@
// Ein Vorschlag für die nächste Orgnummer.
//
// Die Nummer wird eingetragen, nicht erzeugt — wie die Personalnummer, und aus
// demselben Grund: sie stammt aus dem führenden System des Kunden, und eine
// selbst vergebene Nummer wäre eine, die dort niemand kennt. Ein leeres Feld
// in einem Dialog, der sonst zwei Klicks braucht, ist aber die Stelle, an der
// jemand ins Stocken gerät.
//
// Deshalb ein **Vorschlag** und keine Vergabe: erkennbar ist nur, was sich aus
// dem Bestand ablesen lässt, und wenn sich nichts ablesen lässt, steht hier
// nichts. Geraten wird nicht — eine plausibel aussehende, aber erfundene
// Systematik wäre schlimmer als ein leeres Feld, weil sie niemand nachprüft.
//
// Erkannt wird genau ein Muster: gleichbleibender Vorspann, dann Ziffern
// („OE-0013“, „10000001“). Führende Nullen bleiben in der Breite erhalten, die
// der Bestand führt.
const MUSTER = /^(.*?)(\d+)$/;
export function naechsteOrgnummer(vorhandene: readonly string[]): string | undefined {
if (vorhandene.length === 0) return undefined;
let vorspann: string | null = null;
let breite = 0;
let hoechste = 0;
for (const nummer of vorhandene) {
const treffer = MUSTER.exec(nummer.trim());
if (!treffer) return undefined;
const [, kopf, ziffern] = treffer;
// Zwei Systematiken nebeneinander („OE-0013“ und „B7“) — dann gibt es
// keine nächste Nummer, sondern zwei Reihen, und welche gemeint ist,
// weiss nur der Mensch vor dem Dialog.
if (vorspann === null) vorspann = kopf;
else if (vorspann !== kopf) return undefined;
breite = Math.max(breite, ziffern.length);
hoechste = Math.max(hoechste, Number(ziffern));
}
const naechste = hoechste + 1;
// Wächst die Zahl über die bisherige Breite hinaus, wird sie eben länger —
// abzuschneiden hiesse, eine bereits vergebene Nummer vorzuschlagen.
return `${vorspann}${String(naechste).padStart(breite, "0")}`;
}

View File

@@ -136,9 +136,31 @@ export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined
return chain.map((u) => u.name).join(" › ") || "–";
}
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
/**
* Der Bereich über einer Einheit — der nächste Vorfahre, der als Bereich
* geführt wird, die Einheit selbst eingeschlossen.
*
* Hier stand „die oberste Einheit unterhalb der Gesellschaft", also eine
* Aussage über die *Stellung* im Baum statt über das Etikett. Im Altmodell
* fiel beides zusammen: unter der Gesellschaft hingen genau die Bereiche.
* Sobald eine Einheit dazwischenliegt, fällt es auseinander — bei Manner
* hängt unter der Gesellschaft allein „CEO", und die sechs C-Level-Bereiche
* darunter. Die Funktion gab damit für alle 784 Personen „CEO" zurück: in
* der Mitarbeiterliste stand es in jeder Zeile, und auf der Übersicht lagen
* alle im Balken „CEO", während die sechs übrigen Bereiche auf null standen.
*
* Gesucht wird der **nächste** Bereich, nicht der oberste. „CEO" ist selbst
* einer und liegt über den anderen; ohne diese Wahl stünde er wieder überall.
* Dieselbe Regel gilt beim Sortieren (lib/employee-sort.ts) und in den
* Berichten (lib/reports-data.ts) — alle drei müssen denselben Bereich
* meinen, sonst sortiert die Liste nach etwas anderem, als sie anzeigt.
*/
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
const kette = ancestorsOf(maps, unitId);
for (let i = kette.length - 1; i >= 0; i--) {
if (kette[i].unit_type === "Bereich") return kette[i];
}
return undefined;
}
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */

View File

@@ -60,7 +60,16 @@ export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult>
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
// Nachladen weg: sechs Abfragen, jede vollständig.
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
// Zum Stichtag, wie die Planstellen und die Besetzungen darunter. Hier
// stand die Tabelle ungefiltert: eine Einheit, die es 2020 noch nicht gab,
// erschien in der Organisation von 2020. Gemeldet am 29.09. (D.06).
tx
.selectFrom("org_units")
.select(["id", "parent_id"])
.where("valid_from", "<=", asOf)
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
.orderBy("id")
.execute(),
tx
.selectFrom("om_positions as p")

View File

@@ -52,6 +52,11 @@ export type PrintModel = {
totals: { people: number; vacancies: number; units: number };
};
/** Liegt unterhalb dieser Einheit noch ein Bereich? */
function enthaeltBereich(unit: PrintUnit): boolean {
return unit.children.some((k) => k.unitType === "Bereich" || enthaeltBereich(k));
}
function personFrom(e: OrgEmployee): PrintPerson {
return {
id: e.id,
@@ -128,8 +133,9 @@ export function buildPrintModel(
unit.vacancies.sort((a, b) => a.jobTitle.localeCompare(b.jobTitle, "de"));
}
// Summen von unten nach oben. Rekursiv über höchstens vier Ebenen — die
// Tiefe ist durch das Modell begrenzt (Gesellschaft/Bereich/Abteilung/Team).
// Summen von unten nach oben, über beliebig viele Ebenen. `unit_type` ist
// ein Etikett und begrenzt die Tiefe nicht — hier stand einmal das
// Gegenteil.
function zaehle(unit: PrintUnit): number {
const eigene = (unit.chief ? 1 : 0) + unit.members.length;
unit.totalPeople = eigene + unit.children.reduce((s, c) => s + zaehle(c), 0);
@@ -138,7 +144,24 @@ export function buildPrintModel(
if (root) zaehle(root);
else for (const u of byUnit.values()) if (!units.find((x) => x.id === u.id)?.parent_id) zaehle(u);
const divisions = root ? root.children : [...byUnit.values()].filter((u) => u.unitType === "Bereich");
// Ein Blatt je Bereich — und zwar je Bereich, unter dem kein weiterer mehr
// liegt.
//
// Hier stand `root.children`, also „die unmittelbaren Kinder der
// Gesellschaft". Das stimmte, solange die Bereiche unmittelbar darunter
// hingen. Bei Manner liegt „CEO" dazwischen und ist selbst ein Bereich: die
// Auswahl bot genau einen Eintrag an, und der Ausdruck bestand aus der
// Übersicht plus einem einzigen Blatt für CEO.
//
// Nach dem Etikett zu gehen allein genügt nicht — dann käme CEO als siebtes
// Blatt dazu und enthielte die sechs anderen noch einmal. Deshalb bleiben
// nur die untersten Bereiche übrig. Im alten Modell ändert das nichts: dort
// liegt unter einem Bereich nie ein zweiter.
const bereiche = [...byUnit.values()]
.filter((u) => u.unitType === "Bereich" && !enthaeltBereich(u))
.sort((a, b) => a.orgNumber.localeCompare(b.orgNumber, "de"));
// Ohne jedes Etikett „Bereich" bliebe sonst nichts zum Drucken übrig.
const divisions = bereiche.length > 0 ? bereiche : root ? root.children : [];
return {
root,

View File

@@ -42,6 +42,20 @@ export type OpenPositionResolved = {
kostenstelle: { code: string; name: string } | null;
};
/**
* Was der Änderungsdialog von einer Planstelle braucht — und nicht mehr.
*
* Er verlangte bisher eine `OpenPositionResolved`, also die *unbesetzte*
* Planstelle samt „seit wann offen" und „wer würde sie führen". Damit liess er
* sich nur dort öffnen, wo diese Angaben entstehen: auf der Seite Positionen,
* die ausschliesslich unbesetzte Stellen zeigt. Die Kontierung einer besetzten
* Planstelle war deshalb nirgends änderbar — bei Manner betraf das 784 von 788.
*/
export type PlanstelleZumAendern = Pick<
OpenPositionResolved,
"id" | "position_number" | "title" | "org_unit_id" | "is_chief" | "valid_from" | "valid_to" | "kostenstelle"
>;
/**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe

View File

@@ -1,5 +1,7 @@
import { ABSENCE_TYPES } from "./absence";
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtDate } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
import { MITARBEITERARTEN } from "./mitarbeiterart";
import { parseIsoDateParam } from "./reports";
@@ -49,7 +51,7 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
{ key: "worker", label: "Beschäftigtengruppe", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in", "Lehrling") },
{ key: "mitarbeiterart", label: "Mitarbeiterart", alle: "Alle Mitarbeiterarten", optionen: werte(...MITARBEITERARTEN) },
{ 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: "paygrade", label: "Hay-Grade", alle: "Alle Hay-Grades", optionen: werte(...HAY_GRADE_WERTE) },
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
{
key: "gender",
@@ -178,6 +180,49 @@ export function anzahlKriterien(c: Criteria): number {
return n;
}
/**
* Die gesetzten Kriterien in Worten.
*
* Die Exportkarten nannten nur den Status. Die übrigen Kriterien stehen
* eingeklappt weiter oben und wirken auf jeden Export dieser Seite genauso —
* wer eines gesetzt und vergessen hatte, lud eine Datei herunter, die nach dem
* ganzen Haus aussah und einen Ausschnitt enthielt. Bei einer Datei für ein
* Zielsystem merkt das niemand mehr, sobald sie dort eingespielt ist: dort
* fehlen dann Personen, und es sieht aus wie ein Datenstand, nicht wie ein
* Filter.
*
* `anzahlKriterien` daneben zählt dasselbe; hier steht, *was* es ist.
*/
export function beschreibeKriterien(c: Criteria): string[] {
const teile: string[] = [];
for (const k of AUSWAHL_KRITERIEN) {
const wert = c.auswahl[k.key];
if (!wert) continue;
teile.push(`${k.label}: ${k.optionen.find((o) => o.wert === wert)?.label ?? wert}`);
}
for (const k of JANEIN_KRITERIEN) {
const wert = c.jaNein[k.key];
if (wert) teile.push(`${k.label}: ${wert}`);
}
for (const k of ZEITRAUM_KRITERIEN) {
const z = c.zeitraum[k.key];
if (!z) continue;
if (z.von && z.bis) teile.push(`${k.label} ${fmtDate(z.von)} bis ${fmtDate(z.bis)}`);
else if (z.von) teile.push(`${k.label} ab ${fmtDate(z.von)}`);
else if (z.bis) teile.push(`${k.label} bis ${fmtDate(z.bis)}`);
}
const { stundenVon: von, stundenBis: bis } = c;
if (von !== undefined && bis !== undefined) teile.push(`Wochenstunden ${von} bis ${bis}`);
else if (von !== undefined) teile.push(`Wochenstunden ab ${von}`);
else if (bis !== undefined) teile.push(`Wochenstunden bis ${bis}`);
return teile;
}
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
export function kriterienParameter(): string[] {
return [

View File

@@ -1,6 +1,7 @@
import { ABSENCE_TYPES } from "./absence";
import { ABSENCE_TYPES, statusLabel } from "./absence";
import { BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtName, todayIso, yearsBetweenIso } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { WOCHENTAGE } from "./wochentage";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
@@ -31,6 +32,33 @@ export type GroupDimension =
| "teilzeit_art"
| "weekday";
/**
* Die Kennzahl mit dem, was sie tatsächlich zählt.
*
* „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für eine
* andere Zahl — dasselbe Wort für Verschiedenes, gemeldet als N.02. Der Kunde
* führt zwei Grössen mit festen Namen (Max, 30.09.): **Headcount Aktiv** für
* die Aktiven und **Headcount Aktives Dienstverhältnis** für Aktive plus
* Langzeitabwesende.
*
* Die Kennzahl im Bericht zählt aber, was gerade ausgewählt ist — auch
* Geplante oder Ausgetretene. Ein fester Name wäre dort also schlicht falsch.
* Deshalb: trifft die Auswahl eine der beiden Grössen, steht ihr Name da;
* sonst steht dabei, welche Status gezählt wurden.
*/
export function messgroesseLabel(measure: Measure, statuses: readonly string[]): string {
const basis = MEASURE_LABELS[measure];
if (measure !== "headcount") return basis;
const gewaehlt = new Set(statuses);
if (gewaehlt.size === 1 && gewaehlt.has("Aktiv")) return "Headcount Aktiv";
if (gewaehlt.size === 2 && gewaehlt.has("Aktiv") && gewaehlt.has("Karenz")) {
return "Headcount Aktives Dienstverhältnis";
}
if (gewaehlt.size === 0) return basis;
return `${basis} (${statuses.map((s) => statusLabel(s as EmploymentStatus)).join(", ")})`;
}
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
fte: "FTE",
@@ -51,7 +79,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
contract_type: "Vertragsart",
entry_year: "Eintrittsjahr",
source: "Intern/Extern",
paygrade: "Paygrade",
paygrade: "Hay-Grade",
worker_type: "Beschäftigtengruppe",
collective_agreement: "Kollektivvertrag",
betriebsrat: "Betriebsrat",
@@ -235,20 +263,43 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
}
}
function weekdayRank(key: string): number {
const i = WOCHENTAGE.indexOf(key as Weekday);
return i === -1 ? WOCHENTAGE.length : i;
/**
* Dimensionen, deren Werte eine eigene Reihenfolge haben.
*
* Alles andere wird nach der Kennzahl sortiert, gross zuerst — bei „Bereich"
* oder „Standort" ist das die Antwort auf die Frage, die der Bericht stellt.
* Bei einer Leiter ist es keine: Montag vor Dienstag und HG09 vor HG10 sind
* die Reihenfolge, in der die Werte *sind*, und eine nach Häufigkeit
* umgestellte Leiter liest sich als Zufall.
*
* HG09 ist die unterste Stufe, HG20 die oberste. Der „Generic Grade" (`-`)
* steht vor allen: er ist keine Stufe, sondern ihr Fehlen, und vor der
* niedrigsten ist der Platz, an dem das am wenigsten nach einer Aussage
* aussieht.
*/
const EIGENE_REIHENFOLGE: Partial<Record<GroupDimension, readonly string[]>> = {
weekday: WOCHENTAGE,
paygrade: HAY_GRADE_WERTE,
};
function rang(dim: GroupDimension, key: string): number {
const liste = EIGENE_REIHENFOLGE[dim];
if (!liste) return 0;
const i = liste.indexOf(key);
// Unbekanntes hinten, nicht vorn: ein Wert, den die Liste nicht kennt, soll
// auffallen und nicht die Leiter anführen.
return i === -1 ? liste.length : i;
}
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
function sortiereNachReihenfolge<T extends { key: string }>(items: T[], dim: GroupDimension): T[] {
return [...items].sort((a, b) => rang(dim, a.key) - rang(dim, b.key));
}
// Reused by ReportsPageClient (split legend) and the report export route
// (split columns) to render a `weekday` split chronologically rather than
// (split columns) to render a split in the order its values have rather than
// in first-encountered order; a no-op for every other dimension.
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
return EIGENE_REIHENFOLGE[dim] ? [...keys].sort((a, b) => rang(dim, a) - rang(dim, b)) : keys;
}
// Every dimension other than `weekday` is a strict single-key partition
@@ -326,11 +377,11 @@ export function aggregateReport(
value: measureValue(sRows, measure, asOf),
count: sRows.length,
}));
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
row.split = EIGENE_REIHENFOLGE[split] ? sortiereNachReihenfolge(splitRows, split) : splitRows;
}
rows.push(row);
}
return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
return EIGENE_REIHENFOLGE[group] ? sortiereNachReihenfolge(rows, group) : rows.sort((a, b) => b.value - a.value);
}
export function sumValues(rows: { value: number }[]): number {
@@ -349,7 +400,7 @@ export function totalForRows(rows: { value: number; count: number }[], measure:
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },

View File

@@ -6,7 +6,26 @@
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
export type EmploymentType = "Vollzeit" | "Teilzeit";
export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
/**
* Der Hay-Grade. Der Name der Spalte ist `paygrade` geblieben — sie steht in
* rund zehn SQL-Funktionen, und eine Umbenennung hätte jede davon angefasst,
* ohne dass sich am Inhalt etwas ändert. Die Liste führt lib/hay-grade.ts.
*/
export type PaygradeType =
| "-"
| "HG09"
| "HG10"
| "HG11"
| "HG12"
| "HG13"
| "HG14"
| "HG15"
| "HG16"
| "HG17"
| "HG18"
| "HG19"
| "HG19P"
| "HG20";
export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w";
/**
@@ -207,6 +226,21 @@ export type Database = {
address_country: string | null;
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
email: string | null;
/**
* Dienstliche Adresse, freiwillig — eindeutig, wenn angegeben.
*
* Getrennt von `email`: die private gehört nicht in Dateien, die
* das Haus verlassen. Der Honestly-Export nimmt diese hier.
*/
company_email: string | null;
/**
* Kennung der Person im Lernsystem Cornerstone (CSOD).
*
* Freiwillig und eindeutig. Als Text geführt — führende Nullen
* gingen in einer numerischen Spalte verloren, und gerechnet wird
* mit der Kennung ohnehin nicht.
*/
cornerstone_id: string | null;
phone: string | null;
job_title: string;
location_id: string;
@@ -286,6 +320,8 @@ export type Database = {
city?: string | null;
address_country?: string | null;
email?: string | null;
company_email?: string | null;
cornerstone_id?: string | null;
phone?: string | null;
job_title: string;
location_id: string;
@@ -704,6 +740,9 @@ export type Database = {
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
// Berichtigen, nicht ändern zum Stichtag: ein falscher Name war schon
// vorher falsch. Deshalb ohne `effective_date` (20260924160000).
update_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
@@ -722,6 +761,12 @@ export type Database = {
// Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und
// Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen.
clone_position: { Args: { payload: Record<string, unknown> }; Returns: string };
// Eine Organisationseinheit unter einer bestehenden. Nur anlegen:
// parent_id trägt kein Datum, ein Verschieben änderte deshalb auch die
// Vergangenheit — siehe Migration 20260929120000.
create_org_unit: { Args: { payload: Record<string, unknown> }; Returns: string };
// Nur, wenn nichts darunter hängt und nie etwas hing — siehe 20260929140000.
delete_org_unit: { Args: { payload: Record<string, unknown> }; Returns: void };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };

View File

@@ -49,6 +49,16 @@ export function vorbelegungAus(employee: EmployeeRow): Partial<HireDraftData> {
birthDate: employee.birth_date,
svNummer: employee.sv_nummer ?? "",
email: employee.email ?? "",
// Vorbelegt wie die private Adresse, obwohl eine Dienstadresse beim
// Austritt meist abgedreht wird: der Assistent schickt das Feld in jedem
// Fall mit, und leer geliefert löschte es die gespeicherte Adresse
// stillschweigend. Sichtbar vorbelegt lässt sie sich bewusst ersetzen
// oder leeren.
companyEmail: employee.company_email ?? "",
// Aus demselben Grund vorbelegt wie die Firmenadresse: der Assistent
// schickt das Feld in jedem Fall mit, und leer geliefert löschte es die
// gespeicherte Kennung stillschweigend.
cornerstoneId: employee.cornerstone_id ?? "",
phone: employee.phone ?? "",
locationId: employee.location_id,
besetzung: employee.source,

View File

@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DateField, istMeldbaresDatum } from "@/components/ui/Field";
// Der Anlass: in den Berichten hängt jedes Datumsfeld an der Adresszeile, eine
// Änderung heisst also neu laden. Ein `<input type="date">` meldet beim Tippen
// der Jahreszahl aber viermal — 0002, 0020, 0202, 2026 —, und die ersten drei
// setzten das Feld mitten im Tippen zurück. Mit dem Kalender ging es, weil der
// in einem Zug ein fertiges Datum setzt.
describe("istMeldbaresDatum", () => {
it("nimmt ein vollständiges Datum an", () => {
expect(istMeldbaresDatum("2026-09-30")).toBe(true);
expect(istMeldbaresDatum("1980-01-01")).toBe(true);
});
it("nimmt das leere Feld an, denn kein Datum ist auch eine Aussage", () => {
expect(istMeldbaresDatum("")).toBe(true);
});
it("weist die Zwischenstände beim Tippen ab", () => {
for (const zwischen of ["0002-09-30", "0020-09-30", "0202-09-30"]) {
expect(istMeldbaresDatum(zwischen), zwischen).toBe(false);
}
});
it("weist Bruchstücke ab", () => {
expect(istMeldbaresDatum("2026-09")).toBe(false);
expect(istMeldbaresDatum("2026")).toBe(false);
});
});
describe("DateField", () => {
function feld(onChange: (v: string) => void, value = "") {
render(<DateField label="Von" value={value} onChange={onChange} />);
return screen.getByLabelText("Von") as HTMLInputElement;
}
it("meldet den Zwischenstand nicht, zeigt ihn aber an", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
expect(gemeldet).not.toHaveBeenCalled();
// Der Tippstand bleibt stehen: würde das Feld ihn verwerfen, kämpfte es
// gegen die Eingabe.
expect(input.value).toBe("0002-09-30");
});
it("meldet, sobald die Jahreszahl vollständig ist", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
fireEvent.change(input, { target: { value: "2026-09-30" } });
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("2026-09-30");
});
it("meldet das Leeren", () => {
const gemeldet = vi.fn();
const input = feld(gemeldet, "2026-09-30");
fireEvent.change(input, { target: { value: "" } });
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("");
});
it("reicht beim Verlassen nach, was liegengeblieben ist", () => {
// Kommt in der Praxis kaum vor, ist aber der Rückfall: was nicht gemeldet
// wurde, geht spätestens hier hinaus — sofern es eine Aussage ist.
const gemeldet = vi.fn();
const input = feld(gemeldet);
fireEvent.change(input, { target: { value: "0002-09-30" } });
fireEvent.blur(input);
expect(gemeldet).not.toHaveBeenCalled();
});
it("übernimmt einen Wert, der von aussen kommt", () => {
// „Zurücksetzen" in den Kriterien leert das Feld — der Tippstand darf
// dann nicht stehenbleiben.
const { rerender } = render(<DateField label="Von" value="2026-09-30" onChange={vi.fn()} />);
const input = screen.getByLabelText("Von") as HTMLInputElement;
expect(input.value).toBe("2026-09-30");
rerender(<DateField label="Von" value="" onChange={vi.fn()} />);
expect(input.value).toBe("");
});
});

View File

@@ -263,10 +263,10 @@ describe("HistorieTab — Filter", () => {
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
});
it("blendet mit „Gelaufen“ das Anstehende aus", async () => {
it("blendet mit „Vergangen“ das Anstehende aus", async () => {
const user = userEvent.setup();
render(<HistorieTab history={ALLE} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: "Gelaufen" }));
await user.click(screen.getByRole("button", { name: "Vergangen" }));
expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument();
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
});

View File

@@ -0,0 +1,116 @@
import { render as rtlRender, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
import { ToastProvider } from "@/components/ui/Toast";
vi.mock("@/actions/org", () => ({
createOrgUnit: vi.fn(async () => ({ success: true })),
deleteOrgUnit: vi.fn(async () => ({ success: true })),
}));
// Die Angaben zu einer Einheit laden ihre Planstellen nach; das Aktionsmodul
// zieht über lib/db das Paket `server-only` nach und ist im Test ein
// Importfehler. Geprüft wird hier, was die Ansicht anbietet, nicht was die
// Datenbank liefert.
vi.mock("@/actions/positions", () => ({
ladePlanstellenDerEinheit: vi.fn(async () => ({ planstellen: [], kostenstellen: [], heute: "2026-09-29" })),
updatePosition: vi.fn(async () => ({ success: true })),
setPositionCostCenter: vi.fn(async () => ({ success: true })),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
function render(ui: React.ReactElement) {
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
}
// Der Knopf „Einheit darunter anlegen" hängt allein daran, dass ein Knoten
// eine `unitId` trägt. Verschwindet die still — etwa weil jemand den Baum
// umbaut —, bleibt die Oberfläche fehlerfrei und die Handlung ist weg.
const UNITS: OrgUnitNode[] = [
{ id: "u1", org_number: "OE-0001", name: "Gesellschaft", parent_id: null, unit_type: "Gesellschaft" },
{ id: "u2", org_number: "OE-0010", name: "Produktion", parent_id: "u1", unit_type: "Bereich" },
];
const EMPLOYEES: OrgEmployee[] = [
{
id: "p1",
personnel_number: 1,
first_name: "Anna",
last_name: "Huber",
job_title: "Maschinenbedienung",
manager_id: null,
formal_manager_id: null,
absent: false,
absence_type: null,
org_unit_id: "u2",
is_chief: false,
position_id: "s1",
position_number: "S-0001",
},
];
const VACANCIES: OrgVacancy[] = [
{ position_id: "s2", position_number: "S-0002", job_title: "Schlosser:in", org_unit_id: "u2", is_chief: false },
];
describe("buildUnitTree", () => {
const baum = buildUnitTree(UNITS, EMPLOYEES, VACANCIES);
it("trägt die Einheit an jedem Einheitenknoten", () => {
expect(baum[0].unitId).toBe("u1");
expect(baum[0].children[0].unitId).toBe("u2");
});
it("trägt sie an nichts anderem", () => {
// Eine Person, eine Tätigkeitsgruppe oder eine Vakanz ist keine Einheit —
// ein Plus daran würde etwas anlegen, das woanders hinge.
const bereich = baum[0].children[0];
for (const kind of bereich.children.filter((k) => k.kind !== "role")) {
expect(kind.unitId, `„${kind.label}" darf keine Einheit tragen`).toBeUndefined();
for (const enkel of kind.children) expect(enkel.unitId).toBeUndefined();
}
});
});
describe("PositionTree", () => {
it("bietet je Einheit einen Knopf zum Anlegen an", async () => {
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
// Die Wurzel ist aufgeklappt; ihr Kind ist damit sichtbar.
expect(await screen.findByRole("button", { name: /Einheit unter .*Gesellschaft anlegen/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Einheit unter .*Produktion anlegen/ })).toBeInTheDocument();
});
it("bietet ihn nicht an einer Person an", () => {
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument();
});
it("bietet den Papierkorb nur an, wo nichts darunter hängt", () => {
// „Produktion" trägt eine Person und eine Vakanz, die Gesellschaft trägt
// „Produktion" — entfernbar ist keine von beiden. Ein Knopf, der beim
// Klick eine Absage erteilt, ist schlechter als kein Knopf.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
expect(screen.queryByRole("button", { name: /Einheit .* entfernen/ })).not.toBeInTheDocument();
});
it("bietet ihn an einer leeren Einheit an", () => {
const leer: OrgUnitNode = { id: "u3", org_number: "OE-0411", name: "Neu", parent_id: "u1", unit_type: "Bereich" };
render(<PositionTree employees={EMPLOYEES} units={[...UNITS, leer]} vacancies={VACANCIES} />);
expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument();
});
it("öffnet beim Klick auf eine Einheit deren Angaben", async () => {
// Der Klick auf die Karte war lange ohne Wirkung — die naheliegendste
// Geste in dieser Ansicht, und sie antwortete nicht.
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
await userEvent.click(screen.getByRole("button", { name: /Angaben zu .*Produktion/ }));
const panel = await screen.findByRole("dialog");
expect(within(panel).getByText("OE-0010 · Bereich")).toBeInTheDocument();
// Eine Person, keine Leitung, eine offene Planstelle — genau der Bestand
// aus den Fixtures.
expect(within(panel).getByText("vakant")).toBeInTheDocument();
});
});

View File

@@ -103,6 +103,34 @@ describe("PrintChart — Messung", () => {
});
});
// Von Max gewünscht (30.09.), ausdrücklich zum An- und Abwählen. Die Nummer
// steht hinter dem Namen und nicht in einer eigenen Zeile: auf einem Blatt mit
// zweihundert Kästen kostet jede Zeile Massstab.
describe("PrintChart — Personalnummer", () => {
/** Die Zeile, in der eine Person steht — nicht die Kopfzeile ihrer Einheit. */
function personZeile(nachname: string): string {
return screen.getAllByText(new RegExp(nachname))[0].closest("p")!.textContent ?? "";
}
it("druckt sie erst, wenn sie angehakt ist", async () => {
const user = userEvent.setup();
renderChart();
await zurVorschau(user);
// Wiesinger führt die Gesellschaft und steht damit auf jedem Blatt.
expect(personZeile("Wiesinger")).not.toContain("· 1");
});
it("stellt sie hinter den Namen, sobald sie angehakt ist", async () => {
const user = userEvent.setup();
renderChart();
await user.click(screen.getByLabelText(/Personalnummer/));
await zurVorschau(user);
// Die Leitung steht auch auf Blättern ohne Namensliste — deshalb ist das
// Häkchen auf jeder Tiefe zu haben.
expect(personZeile("Wiesinger")).toContain("· 1");
});
});
describe("PrintChart", () => {
it("fragt zuerst, was gedruckt wird, und zeigt vorher kein Blatt", () => {
renderChart();
@@ -208,3 +236,73 @@ describe("PrintChart", () => {
expect(within(seite).getByText("Steinbacher, Test")).toBeInTheDocument();
});
});
// Beim Kunden liegt zwischen der Gesellschaft und den fachlichen Bereichen
// noch „CEO" — selbst ein Bereich. Daran ist der Druck gescheitert: die
// Auswahl bot einen einzigen Eintrag an, und aus dem ganzen Haus wurde ein
// Blatt für CEO.
describe("PrintChart — ein Bereich über den Bereichen", () => {
const UNITS_TIEF: OrgUnitNode[] = [
unit("ag", "Manner AG", "Gesellschaft", null, "0000"),
unit("ceo", "CEO", "Bereich", "ag", "0080"),
unit("cfo", "CFO", "Bereich", "ceo", "0001"),
unit("coo", "COO", "Bereich", "ceo", "0083"),
unit("w17", "Standortleitung W17", "Abteilung", "coo", "0088"),
unit("schnitte", "Leitung Schnitte", "Team", "w17", "0101"),
];
const PERSONEN_TIEF: OrgEmployee[] = [
// Die Gesellschaft bleibt bewusst ohne Leitung — so ist es beim Kunden.
person("11", "Obermayr", "ceo", true),
person("12", "Hofer", "cfo", true),
person("13", "Gruber", "coo", true),
person("14", "Nagl", "schnitte"),
];
function renderTief() {
return render(
<PrintChart model={buildPrintModel(UNITS_TIEF, PERSONEN_TIEF, [])} asOf="2026-09-23" today="2026-09-23" />
);
}
it("bietet die untersten Bereiche an, nicht den darüber", () => {
renderTief();
expect(screen.getByRole("checkbox", { name: /CFO/ })).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: /COO/ })).toBeInTheDocument();
// CEO enthält die beiden anderen; als eigenes Blatt stünde das ganze Haus
// ein zweites Mal darin.
expect(screen.queryByRole("checkbox", { name: /^CEO/ })).not.toBeInTheDocument();
expect(blaetter()).toHaveLength(0);
});
it("zeichnet die Zwischenebene mit, statt sie zu überspringen", async () => {
const user = userEvent.setup();
renderTief();
await zurVorschau(user);
const uebersicht = blaetter()[0];
// Zweimal: als Überschrift des Blatts und als oberster Kasten.
expect(within(uebersicht).getAllByText("Manner AG")).toHaveLength(2);
// Ohne diese Zeile hinge CFO unmittelbar unter der AG — eine Ebene, die
// es gibt, wäre aus dem Organigramm verschwunden.
expect(within(uebersicht).getByText("CEO")).toBeInTheDocument();
expect(within(uebersicht).getByText("CFO")).toBeInTheDocument();
expect(within(uebersicht).getByText("COO")).toBeInTheDocument();
});
it("gibt jedem untersten Bereich ein Blatt", async () => {
const user = userEvent.setup();
renderTief();
await zurVorschau(user);
// Übersicht plus CFO plus COO.
expect(blaetter()).toHaveLength(3);
});
it("vermerkt bei der Gesellschaft keine unbesetzte Leitung", async () => {
const user = userEvent.setup();
renderTief();
await zurVorschau(user);
// Die oberste Einheit wird nicht geführt, sondern ist das Ganze. Alle
// übrigen Kästen der Übersicht haben eine Leitung.
expect(within(blaetter()[0]).queryByText("Leitung unbesetzt")).not.toBeInTheDocument();
});
});

View File

@@ -43,7 +43,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
paygrade: "HG13",
source: "Extern",
status: "Geplant",
entry_date: EINTRITT,

View File

@@ -36,7 +36,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
monthly_salary_gross: null,
contract_type: "unbefristet",
contract_end_date: null,
paygrade: "C",
paygrade: "HG13",
source: "Extern",
status: "Aktiv",
entry_date: "2017-07-15",

View File

@@ -92,11 +92,30 @@ describe("Gründe für weniger Stunden", () => {
expect([...STUNDEN_GRUENDE]).toContain("Pflegeteilzeit");
});
it("verteilt die vier Teilzeiten vollständig und ohne Überschneidung", () => {
// Keine darf verlorengehen, und keine an beiden Stellen auftauchen —
// sonst hinge dieselbe Sache an zwei Vorgängen.
const verteilt = [...RUECKKEHR_GRUENDE, ...STUNDEN_GRUENDE].filter((g) => TEILZEITEN.includes(g));
expect(new Set(verteilt)).toEqual(new Set(TEILZEITEN));
expect(verteilt.length).toBe(TEILZEITEN.length);
it("bietet alle vier Teilzeiten bei der Stundenänderung an", () => {
// Bis zum 29.09. standen Eltern- und Wiedereingliederungsteilzeit nur bei
// der Rückkehr — mit der Begründung, dass sie typischerweise dann
// beginnen. Typischerweise ist nicht immer, und wer sie ohne
// vorangegangene Abwesenheit vereinbart, fand sie nirgends. Gemeldet von
// Lara: „Teilzeitvariante Elternteilzeit fehlt".
for (const teilzeit of TEILZEITEN) {
expect([...STUNDEN_GRUENDE], `„${teilzeit}" fehlt bei der Stundenänderung`).toContain(teilzeit);
}
});
it("überschneidet sich dabei bewusst mit der Rückkehr", () => {
// Die Überschneidung ist kein Versehen: es ist derselbe Wert in derselben
// Spalte, nur aus zwei Anlässen erfasst. Was es nicht geben darf, ist eine
// Teilzeit, die an *keinem* der beiden Wege hängt.
const beide = TEILZEITEN.filter(
(t) => ([...RUECKKEHR_GRUENDE] as string[]).includes(t) && ([...STUNDEN_GRUENDE] as string[]).includes(t)
);
expect(new Set(beide)).toEqual(new Set(RUECKKEHR_GRUENDE));
});
it("führt den Normalfall nicht als Teilzeit", () => {
// „Vertragliche Stundenänderung" ist keine Variante, sondern ihr Fehlen:
// sie landet als null in teilzeit_art.
expect([...TEILZEITEN]).not.toContain("Vertragliche Stundenänderung");
});
});

View File

@@ -0,0 +1,341 @@
import { describe, expect, it } from "vitest";
import {
baueCornerstoneZeile,
benutzername,
beschaeftigungsStatus,
CORNERSTONE_CSV,
CORNERSTONE_SPALTEN,
cornerstoneSpalten,
csDatum,
DIVISION_GUID,
geschlecht,
kontoStatus,
type CornerstoneKontext,
type CornerstoneQuelle,
} from "@/lib/cornerstone";
import { toCsv } from "@/lib/export";
// Die Datei geht in ein fremdes System, das sie über eine im Portal
// eingerichtete Zuordnung einliest. Zwei Sorten Fehler sind hier teuer:
//
// * Ein deutscher Anzeigename statt des Systemwerts („Aktiv" statt
// „Active") weist Cornerstone mit „ungültiger Wert" ab — und zwar die
// ganze Zeile, gefolgt von „Alle abhängigen Felder müssen gültig sein".
// * Ein Datum in der falschen Reihenfolge fällt gar nicht auf: 09.03. und
// 03.09. sind beide gültig.
//
// Deshalb steht hier beides fest, Wert für Wert.
// Die Kopfzeile aus der Vorlage, Zeichen für Zeichen.
const VORGABE =
"User ID,Local System ID,Prefix,First Name,Middle Name,Last Name,Suffix,Username,Approver,Manager,Absent,Allow Reconciliation,Email,Required Training Approvals,Gender,Exempt,Ethnicity,Months of Service,Compensation Currency,Address Line 1,Address Line 2,City,State,Zip,Mailstop,Country,Original Hire Date,Last Hire Date,Phone,Mobile,Home Phone,Fax,Status,User activation date,User de-activation date,Personal Email,Language,Time Zone,User Type,Employment Status,Leave Reason,Termination Type,Termination Reason,Termination Date,Eligible For Rehire,Rehired Employee,Division ID,Position ID,Grade ID,Cost Center ID,Location ID,Retention Rules,Organisationsstufe,Customfield ID AD,Customfield ID Doxis,Customfield ID Interflex,Customfield ID LGVplus";
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
// Die Kennung, unter der Cornerstone die Person kennt — seit 20260928100000
// fuehrt Alpenwerk sie mit, und sie steht in User ID und Username.
const CS_ID = "10000382";
const kontext: CornerstoneKontext = {
managerKennung: new Map([
["chef", "10000117"],
// Eine vorgesetzte Person, die selbst keine Cornerstone-ID trägt — bei 435
// von 785 Personen der Normalfall, nicht der Sonderfall.
["chef-ohne-kennung", null],
]),
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
};
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
return {
id: UUID,
personnel_number: 9002,
first_name: "Max",
last_name: "Alpentest",
title_prefix: [],
title_suffix: [],
gender: "m",
company_email: "m.test@alpenwerk.test.com",
cornerstone_id: CS_ID,
address: "Teststrasse 2",
postal_code: "1010",
city: "Wien",
address_country: "AT",
entry_date: "2026-10-01",
exit_date: null,
exit_reason: null,
austrittsart: null,
absence_type: null,
status: "Aktiv",
paygrade: "HG15",
location_id: "wien",
position_id: "pos-1",
manager_id: null,
...teil,
};
}
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
describe("Spalten", () => {
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
});
it("sind 57", () => {
expect(CORNERSTONE_SPALTEN).toHaveLength(57);
});
it("decken sich mit den Feldern, die eine Zeile liefert", () => {
// Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge
// stillschweigend verloren.
expect(Object.keys(zeile()).sort()).toEqual([...CORNERSTONE_SPALTEN].sort());
});
it("schreibt die Datei mit Komma und ohne BOM", () => {
// Stünden die Vorgaben von toCsv drin (Semikolon fürs deutsche Excel,
// BOM gegen Buchstabensalat), käme in Cornerstone eine einspaltige Datei
// an, deren Spalte auch noch ein unsichtbares Zeichen im Namen trägt.
expect(CORNERSTONE_CSV).toEqual({ trenner: ",", bom: false });
});
});
describe("Systemwerte statt Anzeigenamen", () => {
it("setzt Status auf Active beziehungsweise Inactive", () => {
expect(kontoStatus("Aktiv")).toBe("Active");
expect(kontoStatus("Karenz")).toBe("Active");
expect(kontoStatus("Ausgetreten")).toBe("Inactive");
});
it("setzt Employment Status auf Working, On Leave oder Terminated", () => {
expect(beschaeftigungsStatus("Aktiv")).toBe("Working");
expect(beschaeftigungsStatus("Karenz")).toBe("On Leave");
expect(beschaeftigungsStatus("Ausgetreten")).toBe("Terminated");
// „Geplant" kennt Cornerstone nicht — die Person bekommt ein Konto.
expect(beschaeftigungsStatus("Geplant")).toBe("Working");
});
it("schreibt nirgends einen deutschen Anzeigenamen in die Statusfelder", () => {
// Der häufigste Fehler aus früheren Loads.
const z = zeile();
expect([z.Status, z["Employment Status"], z["User Type"]]).toEqual(["Active", "Working", "Employee"]);
});
it("lässt die Zeitzone leer, statt ein Kürzel zu schreiben", () => {
// Gültig ist allein eine Zeitzonen-ID aus dem Portal; „CET" quittiert
// der Load mit „Zeitzonencode nicht eindeutig".
expect(zeile()["Time Zone"]).toBe("");
});
it("übersetzt das Geschlecht in Cornerstones Werte", () => {
expect(geschlecht("w")).toBe("female");
expect(geschlecht("m")).toBe("male");
// Alles andere ist nicht „leer", sondern ein gültiger Wert — sonst weist
// Cornerstone die ganze Zeile ab.
expect(geschlecht("x")).toBe("not specified");
});
it("schreibt das Datum mit dem Tag zuerst", () => {
expect(csDatum("2026-10-01")).toBe("01.10.2026");
expect(csDatum(null)).toBe("");
});
it("baut den Anmeldenamen ohne Umlaute und Sonderzeichen", () => {
expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross");
expect(benutzername("Anna Maria", "Berger")).toBe("annamaria.berger");
});
});
describe("Eine Zeile gegen das Muster vom 27.09.2026", () => {
it("trägt dieselben Werte wie der Testload", () => {
expect(zeile()).toMatchObject({
"Local System ID": "9002",
"First Name": "Max",
"Last Name": "Alpentest",
Username: CS_ID,
Email: "m.test@alpenwerk.test.com",
Gender: "male",
"Compensation Currency": "EUR",
"Address Line 1": "Teststrasse 2",
City: "Wien",
Zip: "1010",
Country: "AT",
"Original Hire Date": "01.10.2026",
"Last Hire Date": "01.10.2026",
Status: "Active",
Language: "de-DE",
"Time Zone": "",
"User Type": "Employee",
"Employment Status": "Working",
"Division ID": DIVISION_GUID,
"Grade ID": "HG15",
"Cost Center ID": "41130",
"Customfield ID AD": "max.alpentest",
"Customfield ID Doxis": "DOX-9002",
"Customfield ID Interflex": "9002",
"Customfield ID LGVplus": "LGV-9002",
});
});
it("lässt die Felder leer, die das Muster leer lässt", () => {
const z = zeile();
for (const feld of [
"Prefix",
"Middle Name",
"Suffix",
"Approver",
"Manager",
"Absent",
"Allow Reconciliation",
"Required Training Approvals",
"Exempt",
"Ethnicity",
"Months of Service",
"Address Line 2",
"State",
"Mailstop",
"Phone",
"Mobile",
"Home Phone",
"Fax",
"User activation date",
"User de-activation date",
"Personal Email",
"Leave Reason",
"Termination Type",
"Termination Reason",
"Termination Date",
"Eligible For Rehire",
"Rehired Employee",
"Position ID",
"Location ID",
"Retention Rules",
"Organisationsstufe",
]) {
expect(z[feld], feld).toBe("");
}
});
});
describe("Abhängige Felder", () => {
it("füllt die Austrittsfelder nur bei Terminated", () => {
// Ein Austrittsgrund ohne Austritt ist für den Load ein ungültiger
// Zustand, nicht eine Zusatzangabe.
const offen = zeile({ exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
expect(offen["Termination Date"]).toBe("");
expect(offen["Termination Type"]).toBe("");
expect(offen["Termination Reason"]).toBe("");
const weg = zeile({ status: "Ausgetreten", exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
expect(weg["Termination Date"]).toBe("31.12.2026");
expect(weg["Termination Type"]).toBe("freiwillig");
expect(weg["Termination Reason"]).toBe("Kündigung");
expect(weg.Status).toBe("Inactive");
expect(weg["Employment Status"]).toBe("Terminated");
});
it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
expect(karenz["Leave Reason"]).toBe("Elternkarenz");
expect(karenz["Employment Status"]).toBe("On Leave");
});
it("nennt keinen Abwesenheitsgrund an einer ausgetretenen Person", () => {
const weg = zeile({ status: "Ausgetreten", absence_type: "Elternkarenz", exit_date: "2026-12-31" });
expect(weg["Leave Reason"]).toBe("");
});
});
describe("Benutzername und Benutzer-ID", () => {
it("tragen denselben Wert", () => {
// In Alpenwerk sind die beiden stets im Gleichklang. Liefen sie
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
// jeder Verweis darauf (Manager, Berichte) träfe womöglich die falsche.
const z = zeile();
expect(z.Username).toBe(z["User ID"]);
});
it("sind die Cornerstone-ID, nicht die UUID und nicht die Personalnummer", () => {
// Hier stand die Alpenwerk-UUID, solange Alpenwerk die Kennung des
// Lernsystems nicht kannte. Sie sagte dort niemandem etwas.
const z = zeile();
expect(z["User ID"]).toBe(CS_ID);
expect(z.Username).toBe(CS_ID);
expect(z["User ID"]).not.toBe(UUID);
expect(z["User ID"]).not.toBe("9002");
});
it("bleiben leer, wenn keine Cornerstone-ID hinterlegt ist", () => {
// Kein Rueckfall auf die UUID: das braechte zwei Kennungsarten in eine
// Datei, ohne dass es auffiele, und legte in Cornerstone eine zweite
// Person neben der bestehenden an. Eine fehlende Angabe soll fehlen.
const z = baueCornerstoneZeile(person({ cornerstone_id: null }), kontext);
expect(z["User ID"]).toBe("");
expect(z.Username).toBe("");
expect(JSON.stringify(z)).not.toContain(UUID);
});
it("fuehren die LOGA-Personalnummer in Local System ID", () => {
expect(zeile()["Local System ID"]).toBe("9002");
});
it("lassen den Anmeldenamen im Customfield AD stehen", () => {
// Der Verzeichnisdienst kennt die Person unter vorname.nachname; das ist
// nicht der Username in Cornerstone, sondern eine eigene Angabe.
const z = zeile();
expect(z["Customfield ID AD"]).toBe("max.alpentest");
expect(z["Customfield ID AD"]).not.toBe(z.Username);
});
});
describe("Kennungen statt Namen", () => {
it("verweist auf die vorgesetzte Person über deren Cornerstone-ID", () => {
// Cornerstone verknüpft über die User ID, nicht über den Namen und nicht
// über die Personalnummer: die ist in dieser Datei die Local System ID.
expect(zeile({ manager_id: "chef" }).Manager).toBe("10000117");
expect(zeile({ manager_id: null }).Manager).toBe("");
});
it("lässt den Verweis leer, wenn die vorgesetzte Person keine Kennung trägt", () => {
// Lieber keine Angabe als eine Kennung der falschen Art: eine
// Personalnummer im Feld Manager sähe aus wie eine User ID und träfe im
// Zielsystem womöglich eine andere Person.
expect(zeile({ manager_id: "chef-ohne-kennung" }).Manager).toBe("");
});
it("nimmt als Kostenstelle den Code der Planstelle", () => {
expect(zeile()["Cost Center ID"]).toBe("41130");
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
});
it("schreibt die Division als GUID", () => {
expect(zeile()["Division ID"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
});
describe("als CSV", () => {
it("schreibt die Kopfzeile Zeichen für Zeichen wie die Vorlage", () => {
const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV);
expect(csv.startsWith("User ID,")).toBe(true);
expect(csv.split(/\r?\n/)[0]).toBe(VORGABE);
});
it("trennt die Zeilen mit CRLF", () => {
expect(toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV)).toContain("\r\n");
});
it("maskiert ein Komma im Wert, statt die Zeile zu zerreissen", () => {
const csv = toCsv([zeile({ address: "Teststrasse 2, Stiege 3" })], cornerstoneSpalten(), CORNERSTONE_CSV);
expect(csv).toContain('"Teststrasse 2, Stiege 3"');
// Felder zählen, nicht Kommas: das maskierte Komma steht ja in der Zeile.
const felder = (t: string) => t.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
const [kopf, daten] = csv.split(/\r?\n/);
expect(felder(daten)).toBe(felder(kopf));
expect(felder(kopf)).toBe(57);
});
it("lässt die private Adresse nirgends durch", () => {
const csv = toCsv([zeile({ company_email: null })], cornerstoneSpalten(), CORNERSTONE_CSV);
expect(csv).not.toContain("@");
});
});

View File

@@ -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");

View File

@@ -89,7 +89,7 @@ describe("SORTIERFELDER", () => {
expect(SORTIERFELDER.map((f) => f.label)).toEqual([
"Mitarbeiter:in",
"Pers.-Nr.",
"Bereich/Team",
"Organisationseinheit",
"Standort",
"Eintritt",
"Beschäftigung",
@@ -191,24 +191,17 @@ describe("das erzeugte SQL", () => {
}
});
it("holt Bereich und Team über die laufende Besetzung", () => {
const sql = ordnung("bereich", "asc");
it("holt die Organisationseinheit über die laufende Besetzung", () => {
const sql = ordnung("einheit", "asc");
expect(sql).toContain("position_assignments");
expect(sql).toContain("pa.valid_to is null");
// Der Bereich ist die Ebene unter der Gesellschaft, nicht die Einheit
// selbst — sonst stünde in der Spalte etwas anderes als sortiert wird.
expect(sql).toContain("unit_type = 'Bereich'");
expect(sql).toContain("org_units");
// Und über *alle drei* Ebenen: wer in einem Team sitzt, hat den Bereich
// zwei Sprünge über sich. Bliebe nur die eigene Einheit übrig, stünden
// alle Teammitglieder ohne Bereich da und rutschten ans Listenende.
//
// Dass drei Ebenen reichen, ist eine Aussage über die Daten (der
// Aufzählungstyp org_unit_type kennt genau vier Stufen) und lässt sich
// hier nicht prüfen — dass sie überhaupt abgefragt werden, schon.
expect(sql).toContain("ou.unit_type = 'Bereich'");
expect(sql).toContain("e1.unit_type = 'Bereich'");
expect(sql).toContain("e2.unit_type = 'Bereich'");
// Sortiert wird nach der Einheit selbst, nicht nach dem Bereich darüber:
// die Spalte zeigt seit 22.09.2026 nur noch die Einheit, und eine
// Sortierung nach einem Wert, der nirgends steht, sieht von aussen aus
// wie gar keine Sortierung.
expect(sql).not.toContain("unit_type");
});
it("sortiert den Standort nach seinem Namen, nicht nach seiner Kennung", () => {

View File

@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { HAY_GRADES, HAY_GRADE_STANDARD, HAY_GRADE_WERTE, hayGradeLabel, istHayGrade } from "@/lib/hay-grade";
// Dieselbe Vorsichtsmassnahme wie bei Personenkreis und Mitarbeiterart: die
// Liste steht in der Migration als CHECK und hier für die Oberfläche. Laufen
// die beiden auseinander, bietet das Formular einen Wert an, den die Datenbank
// abweist — und die Benutzerin sieht einen Fehler aus dem Maschinenraum.
const MIGRATION = join(process.cwd(), "db/migrations/20260928140000_hay_grade.sql");
function werteAusDerBedingung(): string[] {
const sql = readFileSync(MIGRATION, "utf8");
const block = sql.match(/constraint chk_hay_grade check \([\s\S]*?\);/)?.[0];
expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined();
return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
}
describe("Die Hay-Grades", () => {
it("stimmen mit der Bedingung in der Datenbank überein", () => {
expect(new Set(werteAusDerBedingung())).toEqual(new Set(HAY_GRADE_WERTE));
});
it("führen den Generic Grade als Vorgabe", () => {
// Die Spalte ist NOT NULL mit genau diesem Vorgabewert, und der Bestand
// hat ihn bei der Umstellung bekommen. Weicht die Oberfläche davon ab,
// bekämen neue Zeilen etwas anderes als die vorhandenen.
expect(HAY_GRADE_WERTE).toContain(HAY_GRADE_STANDARD);
expect(readFileSync(MIGRATION, "utf8")).toContain("set default '-'");
});
it("zeigen den Titel und speichern die Kennung", () => {
// Nur beim Generic Grade gehen beide auseinander — genau deshalb darf die
// Anzeige nicht einfach der gespeicherte Wert sein.
expect(hayGradeLabel("-")).toBe("Generic Grade");
expect(hayGradeLabel("HG15")).toBe("HG15");
for (const g of HAY_GRADES) {
if (g.value !== "-") expect(g.label).toBe(g.value);
}
});
it("lassen einen unbekannten Wert stehen, statt ihn zu verschlucken", () => {
// Eine Zeile aus der Zeit vor der Umstellung soll sichtbar falsch sein,
// nicht unsichtbar leer.
expect(hayGradeLabel("B")).toBe("B");
expect(hayGradeLabel(null)).toBe("–");
});
it("weisen zurück, was nicht in der Liste steht", () => {
expect(istHayGrade("HG19P")).toBe(true);
expect(istHayGrade("HG21")).toBe(false);
expect(istHayGrade("B")).toBe(false);
expect(istHayGrade(null)).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
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";
// Die Schlüssel sind Bezeichnungen aus `app_feld_karte()`. Stimmt eine nicht
// mehr — umbenannt, vertippt —, fällt das nirgends auf: das Feld bliebe im
// Dialog „Eintrag berichtigen" einfach ein Textfeld, und getippte Werte
// landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den
// diese Datei beheben soll.
//
// 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[] {
// Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'),
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“", () => {
it("nennen nur Felder, die es in der Feldkarte gibt", () => {
const bekannt = new Set(bezeichnungenDerFeldkarte());
expect(bekannt.size).toBeGreaterThan(30);
for (const feld of Object.keys(FELD_AUSWAHLEN)) {
expect(bekannt, `„${feld}“ steht nicht in app_feld_karte()`).toContain(feld);
}
});
it("bieten für das Verhältnis des Notfallkontakts eine Liste an", () => {
// Der gemeldete Fall: dort liess sich freier Text eintragen, während die
// Erfassung überall sonst eine Liste führt.
expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Gattin/Gatte");
expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Tochter");
});
it("lassen Felder ohne feste Liste als Textfeld stehen", () => {
expect(auswahlFuer("Adresse")).toBeUndefined();
expect(auswahlFuer("Wochenstunden")).toBeUndefined();
});
it("führen keine Felder mit Aufzählungstyp", () => {
// Dort ist der gespeicherte Wert nicht die Anzeige (`m`/`w`,
// `true`/`false`). Eine Liste, die die Anzeigeform schreibt, liefe in
// einen Umwandlungsfehler oder in einen stillen Fehlwert.
for (const feld of ["Geschlecht", "Beschäftigungsausmaß", "Vertragsart", "Besetzungsart", "Betriebsrat"]) {
expect(auswahlFuer(feld), `„${feld}“ gehört nicht in die Auswahl`).toBeUndefined();
}
});
});

160
tests/unit/honestly.test.ts Normal file
View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { toCsv } from "@/lib/export";
import { baueHonestlyZeilen, honestlySpalten, orgKette, ouUeberschrift } from "@/lib/honestly";
import { buildOrgMaps, type OrgUnit } from "@/lib/org";
// Die Datei geht an eine fremde Plattform, die sie über Spaltennamen
// zuordnet. Ein Fehler darin zeigt sich nicht hier, sondern dort — als
// Befragung, die an die falschen Leute geht oder nach der falschen Einheit
// auswertet. Deshalb wird hier die Form festgehalten, nicht nur der Inhalt.
const units: OrgUnit[] = [
{ id: "g", org_number: "1", name: "Manner AG", parent_id: null, unit_type: "Gesellschaft" },
{ id: "b", org_number: "2", name: "Produktion", parent_id: "g", unit_type: "Bereich" },
{ id: "a", org_number: "3", name: "Waffel", parent_id: "b", unit_type: "Abteilung" },
{ id: "t", org_number: "4", name: "Linie 1", parent_id: "a", unit_type: "Team" },
{ id: "v", org_number: "5", name: "Vertrieb", parent_id: "g", unit_type: "Bereich" },
];
const maps = buildOrgMaps(units, []);
const standorte = new Map([["wien", "Wien"], ["wolkersdorf", "Wolkersdorf"]]);
function person(teil: Partial<Parameters<typeof baueHonestlyZeilen>[0][number]> = {}) {
return {
personnel_number: 4711,
// Die private Adresse, wie sie auf der Zeile aus der Datenbank steht —
// sie darf im Export nirgends auftauchen.
email: "anna.privat@example.test",
company_email: "a.berger@firma.test",
first_name: "Anna",
last_name: "Berger",
location_id: "wien",
org_unit_id: "t",
...teil,
};
}
describe("orgKette", () => {
it("fängt bei der eigenen Einheit an und endet ganz oben", () => {
// OU ist die Einheit der Person, OU+1 die darüber — so ist es bestellt.
// ancestorsOf liefert es andersherum; ohne das Umdrehen stünde die
// Gesellschaft in der Spalte „OU".
expect(orgKette(maps, "t")).toEqual(["Linie 1", "Waffel", "Produktion", "Manner AG"]);
});
it("ist leer ohne Einheit", () => {
expect(orgKette(maps, null)).toEqual([]);
});
});
describe("Spalten", () => {
it("stehen in der vorgegebenen Reihenfolge", () => {
expect(honestlySpalten(3).map((s) => s.header)).toEqual([
"Personalnummer",
"Email",
"Firstname",
"Last Name",
"Language",
"Location",
"OU",
"OU+1",
"OU+2",
"Role",
]);
});
it("heissen OU, OU+1, OU+2 …", () => {
expect([0, 1, 2, 5].map(ouUeberschrift)).toEqual(["OU", "OU+1", "OU+2", "OU+5"]);
});
it("behalten mindestens eine OU-Spalte", () => {
// Eine Datei, deren Spalten je nach Inhalt verschwinden, bricht die
// Zuordnung, die auf der anderen Seite eingerichtet wurde.
expect(honestlySpalten(0).filter((s) => s.header.startsWith("OU"))).toHaveLength(1);
});
});
describe("baueHonestlyZeilen", () => {
it("richtet die Zahl der OU-Spalten nach der tiefsten Kette", () => {
const { tiefe } = baueHonestlyZeilen([person({ org_unit_id: "v" }), person({ org_unit_id: "t" })], maps, standorte);
expect(tiefe).toBe(4);
});
it("füllt kürzere Ketten hinten mit Leerzellen auf", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person({ org_unit_id: "v" }), person()], maps, standorte);
const spalten = honestlySpalten(tiefe);
const werte = (i: number) => spalten.map((s) => s.get(zeilen[i]));
// Vertrieb hängt direkt unter der Gesellschaft: zwei Einheiten, zwei leere.
expect(werte(0).slice(6, 10)).toEqual(["Vertrieb", "Manner AG", "", ""]);
expect(werte(1).slice(6, 10)).toEqual(["Linie 1", "Waffel", "Produktion", "Manner AG"]);
});
it("setzt Sprache und Rolle fest", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])]));
expect(zeile.Language).toBe("de");
expect(zeile.Role).toBe("Respondee");
});
it("übernimmt die Personendaten unverändert", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])]));
expect(zeile).toMatchObject({
Personalnummer: 4711,
Firstname: "Anna",
"Last Name": "Berger",
Location: "Wien",
});
});
it("nimmt die Firmenadresse in die E-Mail-Spalte", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])]));
expect(zeile.Email).toBe("a.berger@firma.test");
});
it("lässt die Spalte leer, wenn keine Firmenadresse hinterlegt ist", () => {
// Eine fehlende Dienstadresse ist eine fehlende Angabe — und keine
// Ausrede, ersatzweise die private zu nehmen.
const { zeilen, tiefe } = baueHonestlyZeilen([person({ company_email: null })], maps, standorte);
const zeile = Object.fromEntries(honestlySpalten(tiefe).map((s) => [s.header, s.get(zeilen[0])]));
expect(zeile.Email).toBe("");
});
it("trägt die private Adresse in keine Spalte ein", () => {
// Nicht nur „Email" prüfen: rutschte sie in eine andere Spalte, fiele es
// dort erst in Honestly auf.
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const csv = toCsv(zeilen, honestlySpalten(tiefe));
expect(csv).not.toContain("anna.privat@example.test");
expect(csv).toContain("a.berger@firma.test");
});
it("nimmt auch dann nicht die private Adresse, wenn keine dienstliche da ist", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person({ company_email: null })], maps, standorte);
expect(toCsv(zeilen, honestlySpalten(tiefe))).not.toContain("anna.privat@example.test");
});
it("behält die Spalte Email, auch solange sie leer ist", () => {
// Die Zuordnung in Honestly wird einmal eingerichtet. Kommt die
// Firmenadresse später dazu, soll sich an der Datei nur der Inhalt ändern.
expect(honestlySpalten(1).map((s) => s.header)).toContain("Email");
});
it("gibt Personen ohne Einheit eine leere Kette, aber eine Zeile", () => {
// Wer nicht zugeordnet ist, soll trotzdem befragt werden.
const { zeilen, tiefe } = baueHonestlyZeilen([person({ org_unit_id: null })], maps, standorte);
expect(zeilen).toHaveLength(1);
expect(tiefe).toBe(1);
expect(honestlySpalten(tiefe).find((s) => s.header === "OU")?.get(zeilen[0])).toBe("");
});
});
describe("als CSV", () => {
it("schreibt die Kopfzeile genau so, wie Honestly sie zuordnet", () => {
const { zeilen, tiefe } = baueHonestlyZeilen([person()], maps, standorte);
const kopf = toCsv(zeilen, honestlySpalten(tiefe)).replace(/^/, "").split(/\r?\n/)[0];
expect(kopf).toContain("Personalnummer");
expect(kopf).toContain("Last Name");
expect(kopf).toMatch(/OU\+3.*Role/);
});
});

170
tests/unit/lolyo.test.ts Normal file
View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { toCsv } from "@/lib/export";
import {
anrede,
baueLolyoZeile,
LOLYO_CSV,
LOLYO_EMAIL_BESTAETIGT,
LOLYO_GRUPPE,
LOLYO_SPALTEN,
LOLYO_SPRACHE,
lolyoSpalten,
ohneTypografie,
type LolyoQuelle,
} from "@/lib/lolyo";
// Die Datei legt in LOLYO Konten an. Eine falsche Spalte fällt hier nicht auf,
// sondern dort — als Konto, das niemandem gehört, oder als Zeile, die der
// Import abweist.
// Die Kopfzeile aus der Vorlage des Anbieters, Zeichen für Zeichen.
const VORGABE = "Benutzer/Code;Email;Passwort;Titel prefix;Vorname;Nachname;Title Suffix;Position;Gruppen;Telefon;Anrede;Sprache;E-Mail bestätigt";
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
const PASSWORT = "Startpasswort1!";
function person(teil: Partial<LolyoQuelle> = {}): LolyoQuelle {
return {
id: UUID,
company_email: "max.alpentest@alpenwerk-firmentest.com",
title_prefix: ["Mag."],
title_suffix: ["MBA"],
first_name: "Max",
last_name: "Alpentest",
job_title: "HR-Manager",
phone: "+4366412345678",
gender: "m",
...teil,
};
}
const zeile = (teil: Partial<LolyoQuelle> = {}) => baueLolyoZeile(person(teil), PASSWORT);
describe("Spalten", () => {
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
expect(LOLYO_SPALTEN.join(";")).toBe(VORGABE);
});
it("sind 13", () => {
expect(LOLYO_SPALTEN).toHaveLength(13);
});
it("werden alle gefüllt — keine Spalte ohne Zuordnung", () => {
const z = zeile();
for (const spalte of LOLYO_SPALTEN) {
expect(z, `Spalte "${spalte}" fehlt in der Zeile`).toHaveProperty(spalte);
}
expect(Object.keys(z).sort()).toEqual([...LOLYO_SPALTEN].sort());
});
});
describe("Die Zuordnung", () => {
it("nimmt als Benutzercode die Alpenwerk-UUID", () => {
expect(zeile()["Benutzer/Code"]).toBe(UUID);
});
it("nimmt die Firmenadresse und nie die private", () => {
// Die Datei legt Konten bei einem fremden Anbieter an. Eine Privatadresse
// hat dort nichts verloren; ohne Firmenadresse bleibt die Zelle leer.
expect(zeile().Email).toBe("max.alpentest@alpenwerk-firmentest.com");
expect(zeile({ company_email: null }).Email).toBe("");
});
it("trägt das Passwort ein, das die Route hereinreicht", () => {
expect(zeile().Passwort).toBe(PASSWORT);
});
it("setzt Titel, Name und Position aus Alpenwerk", () => {
const z = zeile();
expect(z["Titel prefix"]).toBe("Mag.");
expect(z.Vorname).toBe("Max");
expect(z.Nachname).toBe("Alpentest");
expect(z["Title Suffix"]).toBe("MBA");
expect(z.Position).toBe("HR-Manager");
});
it("fügt mehrere Titel zusammen und lässt keine zu einer leeren Zelle werden", () => {
expect(zeile({ title_prefix: ["Dr.", "Mag."] })["Titel prefix"]).toBe("Dr. Mag.");
expect(zeile({ title_prefix: [], title_suffix: [] })["Titel prefix"]).toBe("");
});
it("setzt Gruppe, Sprache und Bestätigung fest", () => {
const z = zeile();
expect(z.Gruppen).toBe(LOLYO_GRUPPE);
expect(z.Sprache).toBe(LOLYO_SPRACHE);
expect(z["E-Mail bestätigt"]).toBe(LOLYO_EMAIL_BESTAETIGT);
});
it("leitet die Anrede aus dem Geschlecht ab", () => {
expect(anrede("m")).toBe("Herr");
expect(anrede("w")).toBe("Frau");
// Lieber leer als geraten: eine falsche Anrede steht in jeder Nachricht,
// die die App verschickt.
expect(anrede("")).toBe("");
});
it("lässt eine fehlende Telefonnummer leer", () => {
expect(zeile({ phone: null }).Telefon).toBe("");
});
});
describe("Typografische Zeichen", () => {
it("weichen dem Bindestrich", () => {
// Der Gedankenstrich kam im Zielsystem als „–" an. Ein Bindestrich sagt
// dasselbe und ist in beiden Kodierungen dasselbe Byte.
expect(ohneTypografie("Angestellte/r – F&E")).toBe("Angestellte/r - F&E");
expect(ohneTypografie("Lehrling — Wolkersdorf")).toBe("Lehrling - Wolkersdorf");
});
it("gelten für Anführungszeichen, Auslassungspunkte und geschützte Leerzeichen", () => {
expect(ohneTypografie("„Schnitte“")).toBe('"Schnitte"');
expect(ohneTypografie("O’Brien")).toBe("O'Brien");
expect(ohneTypografie("und so weiter …")).toBe("und so weiter ...");
expect(ohneTypografie("W 17")).toBe("W 17");
});
it("lassen Umlaute in Ruhe", () => {
// „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu verstecken,
// das dann auch Namen beträfe — und dort wäre es keine Kosmetik mehr.
expect(ohneTypografie("Arbeiter/in – Bäckerei")).toBe("Arbeiter/in - Bäckerei");
expect(ohneTypografie("Müller-Weiß")).toBe("Müller-Weiß");
});
it("gelten für jede Zelle aus den Daten, nicht nur für die Position", () => {
const z = zeile({ job_title: "Angestellte/r – F&E", last_name: "O’Brien", title_suffix: ["M–A"] });
expect(z.Position).toBe("Angestellte/r - F&E");
expect(z.Nachname).toBe("O'Brien");
expect(z["Title Suffix"]).toBe("M-A");
});
it("gelten nicht für das Passwort", () => {
// Ein ersetztes Zeichen fiele niemandem auf — es gäbe nur ein Konto, in
// das niemand hineinkommt.
expect(baueLolyoZeile(person(), "Pass–wort–2026").Passwort).toBe("Pass–wort–2026");
});
});
describe("Die Datei", () => {
it("ist semikolongetrennt und ohne BOM", () => {
// Das BOM hinge unsichtbar am Namen der ersten Spalte, und
// „Benutzer/Code" käme beim Einlesen in keiner Zuordnung mehr vor.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv.startsWith("Benutzer/Code;")).toBe(true);
expect(csv.charCodeAt(0)).not.toBe(0xfeff);
});
it("beginnt mit der Kopfzeile der Vorlage", () => {
const csv = toCsv([], lolyoSpalten(), LOLYO_CSV);
expect(csv.split("\r\n")[0]).toBe(VORGABE);
});
it("lässt die Telefonnummer unangetastet", () => {
// Der Formelschutz von toCsv stellt jedem Wert mit führendem „+" ein
// Hochkomma voran, damit Excel ihn nicht rechnet. Diese Datei liest eine
// Maschine: dort stünde danach in **jedem** Konto `'+4366…` als Nummer,
// weil jede österreichische Nummer mit „+" beginnt.
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
expect(csv).toContain(";+4366412345678;");
expect(csv).not.toContain("'+43");
});
});

Some files were not shown because too many files have changed in this diff Show More