feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis #43

Merged
florian.wilde merged 1 commits from feat/31-vorgesetzter-manuell into main 2026-10-04 15:10:35 +00:00
19 changed files with 683 additions and 49 deletions
Showing only changes of commit 02ca74e661 - Show all commits
+9
View File
@@ -9,6 +9,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Geändert ### Geändert
- Der Seed macht Petra Klein zur Vorgesetzten von Maria Huber — die Zuordnung aus dem Auftrag, die
sich vorher nirgends speichern ließ (#020)
- Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne - Der Healthcheck `/api/gesundheit` nennt im Fehlerfall keinen Grund mehr. Der Pfad ist ohne
Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und Anmeldung erreichbar, und eine Meldung von Prisma oder `pg` nennt Host, Port, Benutzernamen und
Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die Datenbanknamen. Nach außen steht jetzt nur noch `{"status":"fehler"}` mit Status 503; die
@@ -24,6 +26,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **Vorgesetzter auch für von Hand angelegte Personen.** Die Zuordnung lief allein über die Kennung
des Verzeichnisses, und die ist nur innerhalb einer Verzeichnisquelle eindeutig: wer eine Person
selbst eingetragen hatte, konnte ihr keinen Vorgesetzten geben, und `{{vorgesetzter_sam}}` blieb im
Onboarding-Befehl leer — genau dort, wo er gebraucht wird. Jetzt trägt jede Person eine gepflegte
Zuordnung, die sich auf ihrer Seite setzen und entfernen lässt; der Abgleich schreibt sie aus dem
Verzeichnis mit, und liefert das Verzeichnis keinen Vorgesetzten, bleibt eine von Hand gesetzte
Zuordnung stehen. Bestehende Zuordnungen nimmt die Migration mit (#020)
- Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt - Mitgelieferte Vorlagen tragen einen **stabilen Schlüssel** (`seedSchluessel`). Der Seed erkennt
seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf seine sieben Vorlagen daran statt am Namen — wer eine umbenannte, bekam vorher beim nächsten Lauf
eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt eine zweite, samt doppeltem Schnellstart auf der Geräteseite. Eine bestehende Installation bekommt
+29 -12
View File
@@ -1,10 +1,10 @@
# #020 Vorgesetzter auch für von Hand angelegte Personen # #020 Vorgesetzter auch für von Hand angelegte Personen
**Status:** offen **Status:** erledigt
**Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander **Im Tracker:** [#31](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/31) — die Zählung dort schließt Pull Requests ein und läuft deshalb auseinander
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, backend **Labels:** phase-2, backend
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 1 h 20 min
## Ziel ## Ziel
@@ -26,17 +26,34 @@ Feld — `{{vorgesetzter_sam}}` bleibt dort immer leer.
## Aufgaben ## Aufgaben
- [ ] Entscheiden: eigene Selbstbeziehung `vorgesetzterId` (sauber, aber zwei Wege zur selben - [x] **Entschieden: eigene Selbstbeziehung `vorgesetzterId`** — und die befürchteten „zwei Wege zur
Auskunft) **oder** `vorgesetzterExterneId` auch im Bereich „ohne Quelle“ auflösen (`quelleId selben Auskunft“ gibt es nicht, weil der Abgleich sie mitschreibt. `vorgesetzterExterneId`
IS NULL`), mit stabilen Kennungen für manuelle Personen bleibt damit wie `abteilung` nur der rohe Wert des Verzeichnisses; gelesen wird allein
- [ ] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern `vorgesetzterId`. Die Alternative hätte manuellen Personen eine erfundene `externeId` geben
- [ ] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen müssen, und `externeId` heißt „objectGUID aus dem Verzeichnis“ — das wäre eine Lüge im
- [ ] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager` Datenmodell gewesen
- [x] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
- [x] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
- [x] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A) liefert — und keine Schleife zulassen (A ist Vorgesetzter von B ist Vorgesetzter von A)
- [ ] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen - [x] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf - [x] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
- [ ] Test: die Zuordnung gilt nur innerhalb desselben Kunden - [x] Test: die Zuordnung gilt nur innerhalb desselben Kunden
- [ ] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht - [x] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
## Beim Umsetzen dazugekommen
- **Die Migration führt bestehende Daten nach.** Ohne das wäre `{{vorgesetzter_sam}}` nach dem
Einspielen bei **jeder** Person aus dem Verzeichnis leer gewesen, bis der nächste Abgleich läuft —
und zwar still. Dieselbe Falle wie der Seed-Schlüssel in
[#021](021-stabiler-schluessel-vorlagen.md): ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
An einem nachgebauten Vor-Zustand in der Entwicklungsdatenbank gefahren.
- **Der Seed setzt die Zuordnung nur für Personen, die derselbe Lauf angelegt hat.** Eine leere
Zuordnung sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde; ein zweiter Lauf
darf nichts zurückdrehen.
- **Zwei Schleifen-Prüfungen, nicht eine.** Die Datenbank fängt die kürzeste ab
(`vorgesetzterId <> id`), längere brauchen den Gang nach oben im Dienst — mit Obergrenze, damit
eine schon beschädigte Kette die Suche nicht ewig laufen lässt.
+2 -2
View File
@@ -24,7 +24,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min | | #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | erledigt | v0.1.0 | 8 h | 1 h 30 min |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min | | #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | erledigt | v0.1.0 | 14 h | 2 h 15 min |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min | | #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | erledigt | v0.1.0 | 10 h | 5 h 25 min |
| #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | offen | v0.2.0 | 3 h | – | | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min |
| #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min |
| #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | offen | v0.2.0 | 3 h | – |
| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – | | #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – |
@@ -32,4 +32,4 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min | | #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min |
| #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min | | #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min |
| #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – |
| | **Summe** | | | **153 h** | **37 h 00 min** | | | **Summe** | | | **153 h** | **38 h 20 min** |
@@ -0,0 +1,62 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf
-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht
-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Vorgesetzter auch fuer von Hand angelegte Personen (#020).
--
-- Bisher lief die Auskunft allein ueber `vorgesetzterExterneId`, und die ist nur
-- innerhalb einer Verzeichnisquelle eindeutig: eine Person ohne Quelle konnte
-- keinen Vorgesetzten haben, {{vorgesetzter_sam}} blieb dort immer leer.
ALTER TABLE "Mitarbeiter"
ADD COLUMN "vorgesetzterId" TEXT;
CREATE INDEX "Mitarbeiter_vorgesetzterId_idx" ON "Mitarbeiter"("vorgesetzterId");
ALTER TABLE "Mitarbeiter"
ADD CONSTRAINT "Mitarbeiter_vorgesetzterId_fkey"
FOREIGN KEY ("vorgesetzterId") REFERENCES "Mitarbeiter" ("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Die Mandantengrenze, wie bei Lauf und Geraet: der Vorgesetzte gehoert zum
-- selben Kunden. MATCH SIMPLE ist richtig — ist die Spalte NULL, greift der
-- Fremdschluessel nicht, eine Person ohne Vorgesetzten bleibt also erlaubt.
ALTER TABLE "Mitarbeiter"
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_gleicher_kunde"
FOREIGN KEY ("vorgesetzterId", "kundeId") REFERENCES "Mitarbeiter" ("id", "kundeId")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Niemand ist sein eigener Vorgesetzter. Das ist die kuerzeste Schleife und die
-- einzige, die sich ohne Rekursion abfangen laesst; laengere haelt der Dienst
-- auf (siehe mitarbeiter.ts, vorgesetztenSetzen).
ALTER TABLE "Mitarbeiter"
ADD CONSTRAINT "Mitarbeiter_vorgesetzter_nicht_selbst"
CHECK ("vorgesetzterId" IS NULL OR "vorgesetzterId" <> "id");
-- Nachfuehrung fuer bestehende Installationen.
--
-- Ohne sie waere `{{vorgesetzter_sam}}` nach dieser Migration bei **jeder**
-- Person aus dem Verzeichnis leer, bis der naechste Abgleich laeuft -- und zwar
-- still: die Auskunft lief bisher allein ueber `vorgesetzterExterneId`, der neue
-- Leseweg nimmt nur `vorgesetzterId`. Dieselbe Falle wie beim Seed-Schluessel
-- in #021: ein Umstieg, der die vorhandenen Daten nicht mitnimmt.
--
-- Die Zuordnung laeuft ueber die `externeId` **derselben** Quelle, genau wie der
-- alte Leseweg: die Kennung ist nur dort eindeutig.
UPDATE "Mitarbeiter" AS person
SET "vorgesetzterId" = chef."id"
FROM "Mitarbeiter" AS chef
WHERE person."vorgesetzterExterneId" IS NOT NULL
AND person."quelleId" IS NOT NULL
AND chef."quelleId" = person."quelleId"
AND chef."externeId" = person."vorgesetzterExterneId"
-- Die Mandantengrenze gilt auch hier; stand je eine Zuordnung ueber Kunden
-- hinweg in den Daten, bliebe sie leer statt den Fremdschluessel zu brechen.
AND chef."kundeId" = person."kundeId"
AND chef."id" <> person."id";
+14
View File
@@ -249,7 +249,18 @@ model Mitarbeiter {
/// passende Abteilung bleibt trotzdem erhalten. /// passende Abteilung bleibt trotzdem erhalten.
abteilung String? abteilung String?
titel String? titel String?
/// Der rohe `manager`-Wert aus dem Verzeichnis, aufgeloest auf die `externeId`
/// des Vorgesetzten — je Quelle eindeutig. Wie `abteilung` ist das der Wert
/// des Verzeichnisses, nicht die gepflegte Zuordnung; die steht in
/// `vorgesetzterId`. Leer bei einer von Hand angelegten Person.
vorgesetzterExterneId String? vorgesetzterExterneId String?
/// Die **gepflegte** Zuordnung, und der einzige Leseweg (#020). Der Abgleich
/// schreibt sie aus `vorgesetzterExterneId` mit, von Hand gesetzt geht sie
/// auch ohne Verzeichnis. Darf nicht auf die Person selbst zeigen (CHECK) und
/// nicht auf jemanden eines anderen Kunden (zusammengesetzter Fremdschluessel
/// `Mitarbeiter_vorgesetzter_gleicher_kunde`). Laengere Schleifen haelt der
/// Dienst auf.
vorgesetzterId String?
aktiv Boolean @default(true) aktiv Boolean @default(true)
herkunft Herkunft @default(MANUELL) herkunft Herkunft @default(MANUELL)
zuletztGesehenAm DateTime? zuletztGesehenAm DateTime?
@@ -258,6 +269,8 @@ model Mitarbeiter {
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict) kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict) quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
vorgesetzter Mitarbeiter? @relation("Vorgesetzter", fields: [vorgesetzterId], references: [id], onDelete: Restrict)
untergebene Mitarbeiter[] @relation("Vorgesetzter")
geraete Geraet[] geraete Geraet[]
laeufe Lauf[] laeufe Lauf[]
@@ -270,6 +283,7 @@ model Mitarbeiter {
@@index([abteilung]) @@index([abteilung])
@@index([upn]) @@index([upn])
@@index([sam]) @@index([sam])
@@index([vorgesetzterId])
} }
/// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des /// Vom Admin gepflegt, Vorschlaege entstehen aus den department-Werten des
+28 -4
View File
@@ -57,6 +57,8 @@ export type PersonenVorgabe = {
readonly upn: string; readonly upn: string;
readonly abteilung: string; readonly abteilung: string;
readonly titel: string; readonly titel: string;
/** UPN des Vorgesetzten – aufgeloest, nachdem alle Personen stehen. */
readonly vorgesetzterUpn?: string;
}; };
/** /**
@@ -64,10 +66,11 @@ export type PersonenVorgabe = {
* Haus ohne Verzeichnis entstehen. * Haus ohne Verzeichnis entstehen.
* *
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf. * Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird * Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei — **und die Zuordnung
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId` * wird gespeichert**, seit `vorgesetzterId` es erlaubt (#020). Vorher ging das
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides * nicht: die Auskunft lief allein ueber `vorgesetzterExterneId`, und die zeigt
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt. * auf die `externeId` derselben Verzeichnisquelle, die eine von Hand angelegte
* Person nicht hat.
*/ */
export const PERSONEN: readonly PersonenVorgabe[] = [ export const PERSONEN: readonly PersonenVorgabe[] = [
{ {
@@ -77,6 +80,7 @@ export const PERSONEN: readonly PersonenVorgabe[] = [
upn: "m.huber@firma.example", upn: "m.huber@firma.example",
abteilung: "Buchhaltung", abteilung: "Buchhaltung",
titel: "Sachbearbeiterin Rechnungsprüfung", titel: "Sachbearbeiterin Rechnungsprüfung",
vorgesetzterUpn: "p.klein@firma.example",
}, },
{ {
vorname: "Petra", vorname: "Petra",
@@ -230,6 +234,8 @@ export async function stammdatenSaeen(
let personen = 0; let personen = 0;
const nachUpn = new Map<string, string>(); const nachUpn = new Map<string, string>();
/** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */
const neuAngelegt = new Set<string>();
for (const vorgabe of PERSONEN) { for (const vorgabe of PERSONEN) {
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } }); const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
if (vorhanden) { if (vorhanden) {
@@ -252,9 +258,27 @@ export async function stammdatenSaeen(
}, },
}); });
nachUpn.set(vorgabe.upn, angelegt.id); nachUpn.set(vorgabe.upn, angelegt.id);
neuAngelegt.add(vorgabe.upn);
personen += 1; personen += 1;
} }
/*
* Die Vorgesetzten erst jetzt, wenn alle Personen stehen: eine Zuordnung
* zeigt auf eine Zeile, die beim Anlegen der ersten noch nicht da war.
*
* Nur fuer Personen, die **dieser Lauf** angelegt hat. Eine leere Zuordnung
* sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde — und ein
* zweiter Seed-Lauf darf nichts zurueckdrehen. Dieselbe Regel wie fuer alles
* andere hier.
*/
for (const vorgabe of PERSONEN) {
if (!vorgabe.vorgesetzterUpn || !neuAngelegt.has(vorgabe.upn)) continue;
const personId = nachUpn.get(vorgabe.upn);
const vorgesetzterId = nachUpn.get(vorgabe.vorgesetzterUpn);
if (!personId || !vorgesetzterId) continue;
await db.mitarbeiter.update({ where: { id: personId }, data: { vorgesetzterId } });
}
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe // Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`). // Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : ""; const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
+18 -3
View File
@@ -3,11 +3,17 @@ import { notFound } from "next/navigation";
import { ArrowLeft, Laptop } from "lucide-react"; import { ArrowLeft, Laptop } from "lucide-react";
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv"; import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten"; import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
import { MitarbeiterVorgesetzter } from "@/components/mitarbeiter-vorgesetzter";
import { Herkunft } from "@/generated/prisma/enums"; import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant"; import { kundenEbeneSichtbar } from "@/server/mandant";
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter"; import {
geraeteDesMitarbeiters,
mitarbeiterHolen,
moeglicheVorgesetzte,
vorgesetzterVon,
} from "@/server/queries/mitarbeiter";
/** /**
* Die Seite einer Person (#011). * Die Seite einer Person (#011).
@@ -26,10 +32,13 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
if (!person) notFound(); if (!person) notFound();
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten"); const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([ const [geraete, kundenEbene, vorgesetzter, auswahl] = await Promise.all([
geraeteDesMitarbeiters(person.id, benutzer), geraeteDesMitarbeiters(person.id, benutzer),
kundenEbeneSichtbar(), kundenEbeneSichtbar(),
vorgesetzterVon(person.id, benutzer), vorgesetzterVon(person.id, benutzer),
// Nur holen, wer sie auch braucht: fuer einen Leser ist die Liste aller
// Kolleginnen und Kollegen eine Abfrage ohne Zweck.
darfBearbeiten ? moeglicheVorgesetzte(person.id, benutzer) : [],
]); ]);
return ( return (
@@ -60,8 +69,14 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
) : null} ) : null}
{darfBearbeiten ? ( {darfBearbeiten ? (
<div className="mt-6"> <div className="mt-6 flex flex-col gap-6">
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} /> <MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
<MitarbeiterVorgesetzter
mitarbeiterId={person.id}
vorgesetzterId={vorgesetzter?.id ?? null}
auswahl={auswahl}
ausDemVerzeichnis={person.herkunft === Herkunft.ABGLEICH}
/>
</div> </div>
) : null} ) : null}
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import { vorgesetztenSetzenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
/**
* Vorgesetzten setzen oder entfernen (#020).
*
* Ein Auswahlfeld und ein Knopf, kein eigener Bearbeiten-Modus: die Zuordnung
* ist eine einzelne Angabe, und sie zu aendern soll nicht erst ein Formular
* aufklappen.
*
* Die leere Option heisst ausdruecklich „niemand“ und nicht „—“: sie ist eine
* Angabe, nicht das Fehlen einer. Wer sie waehlt und speichert, entfernt die
* Zuordnung.
*/
export function MitarbeiterVorgesetzter({
mitarbeiterId,
vorgesetzterId,
auswahl,
ausDemVerzeichnis,
}: {
mitarbeiterId: string;
vorgesetzterId: string | null;
auswahl: readonly { readonly id: string; readonly anzeigename: string }[];
ausDemVerzeichnis: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(vorgesetztenSetzenAktion, {});
return (
<div>
<form action={aktion} className="flex flex-wrap items-end gap-2">
<input type="hidden" name="mitarbeiterId" value={mitarbeiterId} />
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm">
<span className="text-nebentext">Vorgesetzter</span>
<select
name="vorgesetzterId"
defaultValue={vorgesetzterId ?? ""}
className="min-h-11 w-full rounded-pille border border-linie bg-flaeche-hoch px-4 text-sm md:min-h-10"
>
<option value="">niemand</option>
{auswahl.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
<button
type="submit"
disabled={laeuft}
className="min-h-11 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
>
Speichern
</button>
</form>
{ausDemVerzeichnis ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">
Diese Person kommt aus dem Verzeichnis. Liefert es einen Vorgesetzten, setzt der nächste Abgleich
die Zuordnung dort wieder nach seinem Stand; liefert es keinen, bleibt die hier gesetzte Zuordnung
stehen.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="mt-2 text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</div>
);
}
+35
View File
@@ -10,6 +10,7 @@ import {
mitarbeiterAnlegen, mitarbeiterAnlegen,
mitarbeiterBearbeiten, mitarbeiterBearbeiten,
mitarbeiterImportieren, mitarbeiterImportieren,
vorgesetztenSetzen,
type MitarbeiterImportAbweisung, type MitarbeiterImportAbweisung,
} from "@/server/services/mitarbeiter"; } from "@/server/services/mitarbeiter";
import type { HandelnderBenutzer } from "@/server/mandant"; import type { HandelnderBenutzer } from "@/server/mandant";
@@ -161,6 +162,40 @@ export async function mitarbeiterAktivSetzenAktion(
} }
} }
/**
* Vorgesetzten setzen oder entfernen (#020).
*
* Ein leeres Feld entfernt die Zuordnung — deshalb `optional` und nicht
* `min(1)`: „niemand“ ist hier eine Angabe und kein Versehen.
*/
export async function vorgesetztenSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ mitarbeiterId: z.string().min(1), vorgesetzterId: z.string().optional() })
.safeParse({
mitarbeiterId: formular.get("mitarbeiterId"),
vorgesetzterId: formular.get("vorgesetzterId") ?? undefined,
});
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await vorgesetztenSetzen(
eingabe.data.mitarbeiterId,
eingabe.data.vorgesetzterId ?? null,
zugang.benutzer,
);
revalidatePath(`/mitarbeiter/${eingabe.data.mitarbeiterId}`);
return { erfolg: eingabe.data.vorgesetzterId ? "Gespeichert." : "Vorgesetzter entfernt." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/** /**
* Personen aus einer CSV-Datei einspielen (#016). * Personen aus einer CSV-Datei einspielen (#016).
* *
+40 -13
View File
@@ -174,11 +174,15 @@ export async function geraeteDesMitarbeiters(
/** /**
* Der Vorgesetzte einer Person – oder `undefined`. * Der Vorgesetzte einer Person – oder `undefined`.
* *
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId` * Gelesen wird **allein** `vorgesetzterId`: die gepflegte Zuordnung, die der
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle * Abgleich aus dem Verzeichnis mitschreibt und die sich auch von Hand setzen
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis * laesst (#020). Vorher lief die Auskunft ueber `vorgesetzterExterneId` und die
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer * `externeId` derselben Quelle — richtig fuer Verzeichnis-Personen, aber eine
* von Hand angelegten Person steht hier nichts, und das ist richtig so. * von Hand angelegte Person konnte so keinen Vorgesetzten haben.
*
* Die Mandantensicht wird trotzdem noch angewandt, obwohl ein zusammengesetzter
* Fremdschluessel dieselbe Grenze haelt: die Rollensicht des Benutzers ist eine
* andere Frage als die Struktur der Daten.
*/ */
export async function vorgesetzterVon( export async function vorgesetzterVon(
mitarbeiterId: string, mitarbeiterId: string,
@@ -186,22 +190,45 @@ export async function vorgesetzterVon(
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> { ): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
const person = await prisma.mitarbeiter.findFirst({ const person = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] }, where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: { quelleId: true, vorgesetzterExterneId: true }, select: { vorgesetzterId: true },
}); });
if (!person?.vorgesetzterExterneId || !person.quelleId) return undefined; if (!person?.vorgesetzterId) return undefined;
const vorgesetzter = await prisma.mitarbeiter.findFirst({ const vorgesetzter = await prisma.mitarbeiter.findFirst({
where: { where: { AND: [{ id: person.vorgesetzterId }, ...(await kundenBedingung(benutzer))] },
AND: [
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
...(await kundenBedingung(benutzer)),
],
},
select: { id: true, anzeigename: true }, select: { id: true, anzeigename: true },
}); });
return vorgesetzter ?? undefined; return vorgesetzter ?? undefined;
} }
/**
* Wen man als Vorgesetzten waehlen kann (#020).
*
* Nur **aktive** Personen desselben Kunden, und die Person selbst nicht. Die
* eigene Zeile und ein fremder Kunde fielen sonst erst im Dienst auf; eine
* Auswahlliste, die unmoegliche Eintraege anbietet, ist eine Falle.
*
* Laengere Schleifen werden hier **nicht** herausgefiltert: dafuer muesste die
* Liste je Eintrag die Kette hinauflaufen. Der Dienst weist sie ab, mit einem
* Satz, der sagt warum.
*/
export async function moeglicheVorgesetzte(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly { readonly id: string; readonly anzeigename: string }[]> {
const person = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: { kundeId: true },
});
if (!person) return [];
return prisma.mitarbeiter.findMany({
where: { AND: [{ kundeId: person.kundeId, aktiv: true, id: { not: mitarbeiterId } }] },
select: { id: true, anzeigename: true },
orderBy: { anzeigename: "asc" },
});
}
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */ /** Die vorkommenden Abteilungsnamen – fuer den Filter. */
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> { export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
const zeilen = await prisma.mitarbeiter.findMany({ const zeilen = await prisma.mitarbeiter.findMany({
+27 -2
View File
@@ -317,6 +317,7 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich // Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
// erst aufloesen, wenn alle Kennungen bekannt sind. // erst aufloesen, wenn alle Kennungen bekannt sind.
vorgesetzterExterneId: null, vorgesetzterExterneId: null,
vorgesetzterId: null,
aktiv: true, aktiv: true,
herkunft: Herkunft.ABGLEICH, herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn, zuletztGesehenAm: beginn,
@@ -365,7 +366,19 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die // Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
// Kennung. // Kennung.
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}), ...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
...(vorgesetztenLeeren ? { vorgesetzterExterneId: null } : {}), /*
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
*/
...(vorgesetztenLeeren
? {
vorgesetzterExterneId: null,
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
}
: {}),
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine // Wer wieder im Ergebnis steht, ist wieder aktiv — eine
// wiedereingestellte Person behaelt so ihre Laeufe. // wiedereingestellte Person behaelt so ihre Laeufe.
aktiv: true, aktiv: true,
@@ -386,12 +399,24 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
gelesen = bilanz.gesamtGelesen; gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte -------------------------------------- // --- Zweiter Durchgang: Vorgesetzte --------------------------------------
//
// Geschrieben werden beide Felder: `vorgesetzterExterneId` als roher Wert des
// Verzeichnisses, `vorgesetzterId` als die gepflegte Zuordnung, aus der
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
// Hand angelegte Person konnte keinen Vorgesetzten haben.
for (const offen of offene) { for (const offen of offene) {
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null; const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
if (kennung === offen.vorherigeKennung) continue; if (kennung === offen.vorherigeKennung) continue;
const vorgesetzter =
kennung === null
? null
: await prisma.mitarbeiter.findUnique({
where: { quelleId_externeId: { quelleId, externeId: kennung } },
select: { id: true },
});
await prisma.mitarbeiter.updateMany({ await prisma.mitarbeiter.updateMany({
where: { quelleId, externeId: offen.externeId }, where: { quelleId, externeId: offen.externeId },
data: { vorgesetzterExterneId: kennung }, data: { vorgesetzterExterneId: kennung, vorgesetzterId: vorgesetzter?.id ?? null },
}); });
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich // Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
// aktualisiert. // aktualisiert.
+88
View File
@@ -212,6 +212,94 @@ export async function mitarbeiterBearbeiten(
}); });
} }
/**
* Setzt oder entfernt den Vorgesetzten.
*
* Eigener Weg und nicht Teil von `mitarbeiterBearbeiten`, weil es kein
* abgeglichenes Feld im Sinn der Sperre ist: eine Person aus dem Verzeichnis
* darf hier geaendert werden. Der naechste Abgleich setzt die Zuordnung
* allerdings zurueck, wenn das Verzeichnis einen `manager` liefert — dort ist
* sie dann gepflegt. Liefert es keinen, bleibt die Zuordnung von Hand stehen
* (#020).
*
* `vorgesetzterExterneId` bleibt unangetastet: das ist der rohe Wert des
* Verzeichnisses, nicht die Zuordnung.
*/
export async function vorgesetztenSetzen(
mitarbeiterId: string,
vorgesetzterId: string | null,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
const gewuenscht = vorgesetzterId === null || vorgesetzterId === "" ? null : vorgesetzterId;
if (gewuenscht === vorher.vorgesetzterId) return vorher;
let name = "niemand";
if (gewuenscht !== null) {
if (gewuenscht === mitarbeiterId) {
throw new MitarbeiterFehler("Niemand ist sein eigener Vorgesetzter.");
}
const kuenftig = await prisma.mitarbeiter.findUnique({ where: { id: gewuenscht } });
if (!kuenftig) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
// Dieselbe Pruefung haelt der zusammengesetzte Fremdschluessel fest; hier
// steht sie fuer die Meldung.
if (kuenftig.kundeId !== vorher.kundeId) {
throw new MitarbeiterFehler("Der Vorgesetzte muss zum selben Kunden gehören.");
}
await schleifePruefen(mitarbeiterId, gewuenscht);
name = kuenftig.anzeigename;
}
return prisma.$transaction(async (tx) => {
const nachher = await tx.mitarbeiter.update({
where: { id: mitarbeiterId },
data: { vorgesetzterId: gewuenscht },
});
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`,
benutzerId: benutzer.id,
vorher: auszug(vorher, ["vorgesetzterId"]),
nachher: auszug(nachher, ["vorgesetzterId"]),
},
});
return nachher;
});
}
/**
* Weist eine Schleife ab: A ist Vorgesetzter von B ist Vorgesetzter von A.
*
* Die Datenbank faengt nur die kuerzeste ab (`vorgesetzterId <> id`); laengere
* brauchen einen Gang nach oben. Die Obergrenze ist kein Schoenheitsfehler,
* sondern die Notbremse: waere die Kette schon beschaedigt, liefe die Suche
* sonst ewig.
*/
async function schleifePruefen(mitarbeiterId: string, kuenftigerVorgesetzter: string): Promise<void> {
const gesehen = new Set<string>([mitarbeiterId]);
let aktuell: string | null = kuenftigerVorgesetzter;
for (let schritt = 0; schritt < 100 && aktuell !== null; schritt += 1) {
if (gesehen.has(aktuell)) {
throw new MitarbeiterFehler(
"Das ergäbe eine Schleife: diese Person ist dem künftigen Vorgesetzten " +
"bereits selbst vorgesetzt, direkt oder über weitere Personen.",
);
}
gesehen.add(aktuell);
const naechster: { readonly vorgesetzterId: string | null } | null = await prisma.mitarbeiter.findUnique({
where: { id: aktuell },
select: { vorgesetzterId: true },
});
aktuell = naechster?.vorgesetzterId ?? null;
}
}
/** /**
* Deaktivieren und wieder aktivieren. * Deaktivieren und wieder aktivieren.
* *
+9 -9
View File
@@ -106,7 +106,7 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
titel: true, titel: true,
dn: true, dn: true,
quelleId: true, quelleId: true,
vorgesetzterExterneId: true, vorgesetzterId: true,
}, },
}, },
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } }, abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
@@ -207,18 +207,18 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
} }
/** /**
* Der Vorgesetzte, ueber `vorgesetzterExterneId` **derselben** Verzeichnisquelle. * Der Vorgesetzte, ueber die gepflegte Zuordnung `vorgesetzterId`.
* *
* Die Kennung ist nur innerhalb einer Quelle eindeutig; ohne diese * Vorher lief das ueber `vorgesetzterExterneId` und die `externeId` derselben
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei. * Quelle. Dann blieb `{{vorgesetzter_sam}}` bei jeder von Hand angelegten Person
* leer — genau im Onboarding, wo der Befehl ihn braucht (#020).
*/ */
async function vorgesetztenLesen(person: { async function vorgesetztenLesen(person: {
readonly quelleId: string | null; readonly vorgesetzterId: string | null;
readonly vorgesetzterExterneId: string | null;
}): Promise<{ anzeigename: string; sam: string | null } | null> { }): Promise<{ anzeigename: string; sam: string | null } | null> {
if (!person.vorgesetzterExterneId || !person.quelleId) return null; if (!person.vorgesetzterId) return null;
return prisma.mitarbeiter.findFirst({ return prisma.mitarbeiter.findUnique({
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId }, where: { id: person.vorgesetzterId },
select: { anzeigename: true, sam: true }, select: { anzeigename: true, sam: true },
}); });
} }
+30
View File
@@ -408,6 +408,36 @@ describe.runIf(datenbankVerfuegbar && ldapDa)("Abgleich gegen das Verzeichnis",
expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1); expect(await testPrisma.mitarbeiter.count()).toBe(FIXTURE_PERSONEN + 1);
}); });
it("loescht eine von Hand gesetzte Vorgesetzten-Zuordnung nicht", async () => {
// `kberger` traegt im Verzeichnis keinen `manager` — mhuber und jweiss tun
// es. Wer die Zuordnung fuer kberger von Hand setzt, pflegt etwas, das das
// Verzeichnis nicht fuehrt; also hat es sie auch nicht zu loeschen (#020).
const { admin } = await grundbestand();
const quelle = await quelleFuerContainer(admin);
await abgleichAusfuehren(quelle.id, admin);
const ohneChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "kberger" } });
const vonHand = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "jweiss" } });
expect(ohneChef.vorgesetzterExterneId).toBeNull();
expect(ohneChef.vorgesetzterId).toBeNull();
await testPrisma.mitarbeiter.update({
where: { id: ohneChef.id },
data: { vorgesetzterId: vonHand.id },
});
const zweiter = await abgleichAusfuehren(quelle.id, admin);
expect(zweiter.status).toBe("ERFOLG");
const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: ohneChef.id } });
expect(nachher.vorgesetzterId).toBe(vonHand.id);
// Gegenstueck: wo das Verzeichnis einen `manager` fuehrt, bestimmt es — und
// zwar in **beiden** Feldern, dem rohen Wert und der Zuordnung.
const mitChef = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
expect(mitChef.vorgesetzterId).toBe(ohneChef.id);
expect(mitChef.vorgesetzterExterneId).toBe(ohneChef.externeId);
});
it("setzt ein per userAccountControl deaktiviertes Konto auf aktiv = false", async () => { it("setzt ein per userAccountControl deaktiviertes Konto auf aktiv = false", async () => {
const { admin } = await grundbestand(); const { admin } = await grundbestand();
const quelle = await quelleFuerContainer(admin); const quelle = await quelleFuerContainer(admin);
+2
View File
@@ -31,6 +31,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Lauf_abteilung_gleicher_kunde", "Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde", "Lauf_geraet_gleicher_kunde",
"Lauf_mitarbeiter_gleicher_kunde", "Lauf_mitarbeiter_gleicher_kunde",
"Mitarbeiter_vorgesetzter_gleicher_kunde",
]); ]);
}); });
@@ -83,6 +84,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Lauf_archiviert_mit_zeitpunkt", "Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt", "Lauf_begonnen_mit_zeitpunkt",
"Lauf_mitarbeiter_eindeutig", "Lauf_mitarbeiter_eindeutig",
"Mitarbeiter_vorgesetzter_nicht_selbst",
"Schrittergebnis_begruendung_noetig", "Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form", "TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt", "Verzeichnisquelle_bindpasswort_verschluesselt",
+15
View File
@@ -333,6 +333,21 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
).rejects.toThrow(/Vorlage_seedSchluessel_einmalig/); ).rejects.toThrow(/Vorlage_seedSchluessel_einmalig/);
}); });
it("laesst niemanden sein eigener Vorgesetzter sein", async () => {
// Die kuerzeste Schleife, und die einzige, die sich ohne Rekursion
// abfangen laesst. Laengere haelt der Dienst auf (#020).
const kunde = await kundeAnlegen("VORG");
const person = await testPrisma.mitarbeiter.create({
data: { kundeId: kunde.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
await expect(
testPrisma.mitarbeiter.update({
where: { id: person.id },
data: { vorgesetzterId: person.id },
}),
).rejects.toThrow(/Mitarbeiter_vorgesetzter_nicht_selbst/);
});
it("laesst beliebig viele Vorlagen ohne Seed-Schluessel zu", async () => { it("laesst beliebig viele Vorlagen ohne Seed-Schluessel zu", async () => {
// Der Index ist partiell, sonst waere die zweite eigene Vorlage eine // Der Index ist partiell, sonst waere die zweite eigene Vorlage eine
// Dublette von null (#021). // Dublette von null (#021).
+115
View File
@@ -6,12 +6,15 @@ import {
mitarbeiterAktivSetzen, mitarbeiterAktivSetzen,
mitarbeiterAnlegen, mitarbeiterAnlegen,
mitarbeiterBearbeiten, mitarbeiterBearbeiten,
vorgesetztenSetzen,
} from "@/server/services/mitarbeiter"; } from "@/server/services/mitarbeiter";
import { import {
abteilungsNamen, abteilungsNamen,
geraeteDesMitarbeiters, geraeteDesMitarbeiters,
mitarbeiterHolen, mitarbeiterHolen,
mitarbeiterListe, mitarbeiterListe,
moeglicheVorgesetzte,
vorgesetzterVon,
} from "@/server/queries/mitarbeiter"; } from "@/server/queries/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -40,6 +43,118 @@ describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
await datenbankLeeren(); await datenbankLeeren();
}); });
describe("Vorgesetzter", () => {
it("laesst sich fuer eine von Hand angelegte Person setzen", async () => {
// Der Fall, der vorher gar nicht ging: ohne Verzeichnisquelle gab es kein
// Feld fuer die Zuordnung (#020).
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
expect(maria.herkunft).toBe(Herkunft.MANUELL);
await vorgesetztenSetzen(maria.id, petra.id, admin);
expect(await vorgesetzterVon(maria.id, admin)).toEqual({
id: petra.id,
anzeigename: "Petra Klein",
});
});
it("entfernt die Zuordnung bei leerer Angabe", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
await vorgesetztenSetzen(maria.id, petra.id, admin);
await vorgesetztenSetzen(maria.id, "", admin);
expect(await vorgesetzterVon(maria.id, admin)).toBeUndefined();
});
it("laesst niemanden sein eigener Vorgesetzter sein", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
await expect(vorgesetztenSetzen(maria.id, maria.id, admin)).rejects.toThrow(/eigener Vorgesetzter/);
});
it("weist eine Schleife ueber zwei Personen ab", async () => {
const { admin } = await grundbestand();
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
await vorgesetztenSetzen(b.id, a.id, admin);
await expect(vorgesetztenSetzen(a.id, b.id, admin)).rejects.toThrow(/Schleife/);
});
it("weist eine Schleife ueber drei Personen ab", async () => {
// Die Datenbank faengt nur die kuerzeste ab; diese braucht den Gang nach
// oben im Dienst.
const { admin } = await grundbestand();
const a = await mitarbeiterAnlegen({ vorname: "A", nachname: "Eins" }, admin);
const b = await mitarbeiterAnlegen({ vorname: "B", nachname: "Zwei" }, admin);
const c = await mitarbeiterAnlegen({ vorname: "C", nachname: "Drei" }, admin);
await vorgesetztenSetzen(b.id, a.id, admin);
await vorgesetztenSetzen(c.id, b.id, admin);
await expect(vorgesetztenSetzen(a.id, c.id, admin)).rejects.toThrow(/Schleife/);
});
it("gilt nur innerhalb desselben Kunden", async () => {
const { eigene, admin } = await grundbestand();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const hier = await testPrisma.mitarbeiter.create({
data: { kundeId: eigene.id, vorname: "Maria", nachname: "Huber", anzeigename: "Maria Huber" },
});
const dort = await testPrisma.mitarbeiter.create({
data: { kundeId: fremd.id, vorname: "Petra", nachname: "Klein", anzeigename: "Petra Klein" },
});
await expect(vorgesetztenSetzen(hier.id, dort.id, admin)).rejects.toThrow(/selben Kunden/);
// Und auch an der Service-Schicht vorbei nicht: der zusammengesetzte
// Fremdschluessel haelt dieselbe Grenze.
await expect(
testPrisma.mitarbeiter.update({
where: { id: hier.id },
data: { vorgesetzterId: dort.id },
}),
).rejects.toThrow(/Mitarbeiter_vorgesetzter_gleicher_kunde/);
});
it("bietet zur Auswahl nur aktive Kollegen desselben Kunden, ohne die Person selbst", async () => {
const { eigene, admin } = await grundbestand();
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
const weg = await mitarbeiterAnlegen({ vorname: "Alt", nachname: "Kollege" }, admin);
await mitarbeiterAktivSetzen(weg.id, false, admin);
await testPrisma.mitarbeiter.create({
data: { kundeId: fremd.id, vorname: "Fremd", nachname: "Person", anzeigename: "Fremd Person" },
});
expect(eigene.id).toBe(maria.kundeId);
const auswahl = await moeglicheVorgesetzte(maria.id, admin);
expect(auswahl.map((eintrag) => eintrag.id)).toEqual([petra.id]);
});
it("schreibt die Aenderung ins Protokoll, mit Namen", async () => {
const { admin } = await grundbestand();
const maria = await mitarbeiterAnlegen({ vorname: "Maria", nachname: "Huber" }, admin);
const petra = await mitarbeiterAnlegen({ vorname: "Petra", nachname: "Klein" }, admin);
await vorgesetztenSetzen(maria.id, petra.id, admin);
await vorgesetztenSetzen(maria.id, null, admin);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Mitarbeiter", entitaetId: maria.id },
orderBy: { erstelltAm: "asc" },
select: { aktion: true },
});
// Das Anlegen protokolliert selbst - hier zaehlt, was danach kam.
expect(eintraege.map((e) => e.aktion).slice(1)).toEqual([
"Vorgesetzter auf Petra Klein gesetzt",
"Vorgesetzter entfernt",
]);
});
});
describe("Anlegen", () => { describe("Anlegen", () => {
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => { it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
const { admin } = await grundbestand(); const { admin } = await grundbestand();
+49
View File
@@ -70,7 +70,10 @@ async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
abteilung: "Buchhaltung", abteilung: "Buchhaltung",
titel: "Sachbearbeiterin", titel: "Sachbearbeiterin",
dn: DN, dn: DN,
// Beides, so wie der Abgleich es schreibt: der rohe Wert des
// Verzeichnisses und die gepflegte Zuordnung, aus der gelesen wird (#020).
vorgesetzterExterneId: "guid-chefin", vorgesetzterExterneId: "guid-chefin",
vorgesetzterId: chefin.id,
herkunft: Herkunft.ABGLEICH, herkunft: Herkunft.ABGLEICH,
}, },
}); });
@@ -170,6 +173,52 @@ describe.runIf(datenbankVerfuegbar)("Werte eines Laufs", () => {
}); });
}); });
it("löst den Vorgesetzten auch für eine von Hand angelegte Person auf", async () => {
// Der Fall aus #020: ohne Verzeichnisquelle gab es vorher kein Feld fuer die
// Zuordnung, und {{vorgesetzter_sam}} blieb im Onboarding-Befehl leer -
// genau dort, wo er gebraucht wird.
const { eigene, versionId } = await aufbau();
const chefin = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Petra",
nachname: "Klein",
anzeigename: "Petra Klein",
sam: "pklein",
herkunft: Herkunft.MANUELL,
},
});
const vonHand = await testPrisma.mitarbeiter.create({
data: {
kundeId: eigene.id,
vorname: "Neu",
nachname: "Kollege",
anzeigename: "Neu Kollege",
sam: "nkollege",
herkunft: Herkunft.MANUELL,
vorgesetzterId: chefin.id,
},
});
// Keine Quelle, keine externe Kennung - der alte Leseweg haette hier
// ausgestiegen.
expect(vonHand.quelleId).toBeNull();
expect(vonHand.vorgesetzterExterneId).toBeNull();
const laufId = await laufAnlegen({
vorlagenVersionId: versionId,
titel: "Onboarding Neu Kollege",
kundeId: eigene.id,
mitarbeiterId: vonHand.id,
});
const werte = await platzhalterWerteDesLaufs({
laufId,
technikerName: "Ali Demir",
jetzt: new Date("2026-10-04T10:00:00Z"),
});
expect(werte).toMatchObject({ vorgesetzter: "Petra Klein", vorgesetzter_sam: "pklein" });
});
it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => { it("nimmt für die OU den Eltern-Container des dn, nicht die Ziel-OU", async () => {
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung // Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
// ist die Absicht fuer ein Konto, das es noch nicht gibt. // ist die Absicht fuer ein Konto, das es noch nicht gibt.
+29
View File
@@ -450,6 +450,35 @@ describe.runIf(datenbankVerfuegbar)("Seed gegen die Datenbank", () => {
expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2); expect(await testPrisma.tagCodeVergabe.count()).toBe(OFFENE_TAGS.length + 2);
}); });
it("macht Petra Klein zur Vorgesetzten von Maria Huber", async () => {
// Steht so im Auftrag. Ging vor #020 nicht: eine von Hand angelegte Person
// konnte keinen Vorgesetzten haben.
const eigene = await eigeneOrganisation();
await stammdatenSaeen(testPrisma, eigene.id);
const maria = await testPrisma.mitarbeiter.findFirstOrThrow({
where: { sam: "mhuber" },
include: { vorgesetzter: { select: { anzeigename: true, sam: true } } },
});
expect(maria.vorgesetzter).toEqual({ anzeigename: "Klein, Petra", sam: "pklein" });
});
it("gibt eine im Betrieb entfernte Vorgesetzten-Zuordnung nicht zurueck", async () => {
// Dieselbe Regel wie fuer alles andere im Seed: er legt an, er korrigiert
// nicht.
const eigene = await eigeneOrganisation();
await stammdatenSaeen(testPrisma, eigene.id);
const maria = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { sam: "mhuber" } });
await testPrisma.mitarbeiter.update({
where: { id: maria.id },
data: { vorgesetzterId: null },
});
await stammdatenSaeen(testPrisma, eigene.id);
const nachher = await testPrisma.mitarbeiter.findFirstOrThrow({ where: { id: maria.id } });
expect(nachher.vorgesetzterId).toBeNull();
});
it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => { it("legt den Bestand beim zweiten Lauf nicht noch einmal an", async () => {
const eigene = await eigeneOrganisation(); const eigene = await eigeneOrganisation();
await stammdatenSaeen(testPrisma, eigene.id); await stammdatenSaeen(testPrisma, eigene.id);