feat(mitarbeiter): Vorgesetzter auch fuer Personen ohne Verzeichnis
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Die Zuordnung lief allein ueber vorgesetzterExterneId, und die ist nur
innerhalb einer Verzeichnisquelle eindeutig: eine von Hand angelegte Person
konnte keinen Vorgesetzten haben, und {{vorgesetzter_sam}} blieb im
Onboarding-Befehl leer -- genau dort, wo er gebraucht wird.

Entschieden fuer eine eigene Selbstbeziehung vorgesetzterId. Die befuerchteten
"zwei Wege zur selben Auskunft" entstehen nicht, weil der Abgleich sie
mitschreibt: vorgesetzterExterneId ist jetzt wie abteilung nur der rohe Wert
des Verzeichnisses, gelesen wird allein vorgesetzterId. Die Alternative haette
manuellen Personen eine erfundene externeId geben muessen, und externeId heisst
"objectGUID aus dem Verzeichnis".

Liefert das Verzeichnis keinen manager, bleibt eine von Hand gesetzte Zuordnung
stehen -- geleert wird nur, was das Verzeichnis selbst gesetzt hatte.

Die Migration fuehrt bestehende Daten nach. Ohne das waere die Auskunft nach
dem Einspielen bei jeder Verzeichnis-Person still leer gewesen, bis der
naechste Abgleich laeuft. An einem nachgebauten Vor-Zustand gefahren.

Drei Grenzen, drei Stellen: der zusammengesetzte Fremdschluessel haelt die
Mandantengrenze, ein CHECK die kuerzeste Schleife, und der Dienst laeuft die
Kette hinauf fuer laengere.

Im gebauten Stand durchgeklickt: speichern, neu laden, Wert steht, entfernen,
390 px ohne horizontales Scrollen. Gegenprobe am Abgleich gefahren.

Closes #31
This commit is contained in:
Florian Wilde
2026-10-04 17:09:54 +02:00
parent 0fa41b9e08
commit 02ca74e661
19 changed files with 683 additions and 49 deletions
+9
View File
@@ -9,6 +9,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### 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
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
@@ -24,6 +26,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### 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
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
+29 -12
View File
@@ -1,10 +1,10 @@
# #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
**Meilenstein:** v0.2.0
**Labels:** phase-2, backend
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 1 h 20 min
## Ziel
@@ -26,17 +26,34 @@ Feld — `{{vorgesetzter_sam}}` bleibt dort immer leer.
## Aufgaben
- [ ] Entscheiden: eigene Selbstbeziehung `vorgesetzterId` (sauber, aber zwei Wege zur selben
Auskunft) **oder** `vorgesetzterExterneId` auch im Bereich „ohne Quelle“ auflösen (`quelleId
IS NULL`), mit stabilen Kennungen für manuelle Personen
- [ ] Beide Lesepfade und `{{vorgesetzter}}` / `{{vorgesetzter_sam}}` entsprechend erweitern
- [ ] Oberfläche: Vorgesetzten auf der Mitarbeiterseite setzen und entfernen
- [ ] Der Abgleich darf die Zuordnung nicht überschreiben, wenn das Verzeichnis keinen `manager`
- [x] **Entschieden: eigene Selbstbeziehung `vorgesetzterId`** — und die befürchteten „zwei Wege zur
selben Auskunft“ gibt es nicht, weil der Abgleich sie mitschreibt. `vorgesetzterExterneId`
bleibt damit wie `abteilung` nur der rohe Wert des Verzeichnisses; gelesen wird allein
`vorgesetzterId`. Die Alternative hätte manuellen Personen eine erfundene `externeId` geben
müssen, und `externeId` heißt „objectGUID aus dem Verzeichnis“ — das wäre eine Lüge im
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)
- [ ] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
- [x] Seed: Petra Klein als Vorgesetzte von Maria Huber eintragen
## Akzeptanzkriterien
- [ ] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
- [ ] Test: die Zuordnung gilt nur innerhalb desselben Kunden
- [ ] Test: ein Abgleich ohne `manager` löscht eine von Hand gesetzte Zuordnung nicht
- [x] Test: eine von Hand angelegte Person bekommt einen Vorgesetzten, `{{vorgesetzter_sam}}` löst auf
- [x] Test: die Zuordnung gilt nur innerhalb desselben Kunden
- [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 |
| #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 |
| #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 |
| #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 | – |
@@ -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 |
| #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 | – |
| | **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";
+18 -4
View File
@@ -249,17 +249,30 @@ model Mitarbeiter {
/// passende Abteilung bleibt trotzdem erhalten.
abteilung 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?
/// 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)
herkunft Herkunft @default(MANUELL)
zuletztGesehenAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @default(now()) @updatedAt
kunde Kunde @relation(fields: [kundeId], references: [id], onDelete: Restrict)
quelle Verzeichnisquelle? @relation(fields: [quelleId], references: [id], onDelete: Restrict)
geraete Geraet[]
laeufe Lauf[]
kunde Kunde @relation(fields: [kundeId], 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[]
laeufe Lauf[]
/// Postgres behandelt NULL als verschieden, deshalb stoeren mehrere manuell
/// angelegte Mitarbeiter ohne Quelle und externe Id hier nicht.
@@ -270,6 +283,7 @@ model Mitarbeiter {
@@index([abteilung])
@@index([upn])
@@index([sam])
@@index([vorgesetzterId])
}
/// 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 abteilung: 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.
*
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei; **gespeichert wird
* die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId`
* derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides
* nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt.
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei — **und die Zuordnung
* wird gespeichert**, seit `vorgesetzterId` es erlaubt (#020). Vorher ging das
* nicht: die Auskunft lief allein ueber `vorgesetzterExterneId`, und die zeigt
* auf die `externeId` derselben Verzeichnisquelle, die eine von Hand angelegte
* Person nicht hat.
*/
export const PERSONEN: readonly PersonenVorgabe[] = [
{
@@ -77,6 +80,7 @@ export const PERSONEN: readonly PersonenVorgabe[] = [
upn: "m.huber@firma.example",
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin Rechnungsprüfung",
vorgesetzterUpn: "p.klein@firma.example",
},
{
vorname: "Petra",
@@ -230,6 +234,8 @@ export async function stammdatenSaeen(
let personen = 0;
const nachUpn = new Map<string, string>();
/** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */
const neuAngelegt = new Set<string>();
for (const vorgabe of PERSONEN) {
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
if (vorhanden) {
@@ -252,9 +258,27 @@ export async function stammdatenSaeen(
},
});
nachUpn.set(vorgabe.upn, angelegt.id);
neuAngelegt.add(vorgabe.upn);
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
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
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 { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
import { MitarbeiterVorgesetzter } from "@/components/mitarbeiter-vorgesetzter";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
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).
@@ -26,10 +32,13 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
if (!person) notFound();
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),
kundenEbeneSichtbar(),
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 (
@@ -60,8 +69,14 @@ export default async function MitarbeiterSeite({ params }: { params: Promise<{ i
) : null}
{darfBearbeiten ? (
<div className="mt-6">
<div className="mt-6 flex flex-col gap-6">
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
<MitarbeiterVorgesetzter
mitarbeiterId={person.id}
vorgesetzterId={vorgesetzter?.id ?? null}
auswahl={auswahl}
ausDemVerzeichnis={person.herkunft === Herkunft.ABGLEICH}
/>
</div>
) : 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,
mitarbeiterBearbeiten,
mitarbeiterImportieren,
vorgesetztenSetzen,
type MitarbeiterImportAbweisung,
} from "@/server/services/mitarbeiter";
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).
*
+40 -13
View File
@@ -174,11 +174,15 @@ export async function geraeteDesMitarbeiters(
/**
* Der Vorgesetzte einer Person – oder `undefined`.
*
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId`
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer
* von Hand angelegten Person steht hier nichts, und das ist richtig so.
* Gelesen wird **allein** `vorgesetzterId`: die gepflegte Zuordnung, die der
* Abgleich aus dem Verzeichnis mitschreibt und die sich auch von Hand setzen
* laesst (#020). Vorher lief die Auskunft ueber `vorgesetzterExterneId` und die
* `externeId` derselben Quelle — richtig fuer Verzeichnis-Personen, aber eine
* 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(
mitarbeiterId: string,
@@ -186,22 +190,45 @@ export async function vorgesetzterVon(
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
const person = await prisma.mitarbeiter.findFirst({
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({
where: {
AND: [
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
...(await kundenBedingung(benutzer)),
],
},
where: { AND: [{ id: person.vorgesetzterId }, ...(await kundenBedingung(benutzer))] },
select: { id: true, anzeigename: true },
});
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. */
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
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
// erst aufloesen, wenn alle Kennungen bekannt sind.
vorgesetzterExterneId: null,
vorgesetzterId: null,
aktiv: true,
herkunft: Herkunft.ABGLEICH,
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
// Kennung.
...(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
// wiedereingestellte Person behaelt so ihre Laeufe.
aktiv: true,
@@ -386,12 +399,24 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
gelesen = bilanz.gesamtGelesen;
// --- 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) {
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
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({
where: { quelleId, externeId: offen.externeId },
data: { vorgesetzterExterneId: kennung },
data: { vorgesetzterExterneId: kennung, vorgesetzterId: vorgesetzter?.id ?? null },
});
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
// 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.
*
+9 -9
View File
@@ -106,7 +106,7 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<Pl
titel: true,
dn: true,
quelleId: true,
vorgesetzterExterneId: true,
vorgesetzterId: 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
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei.
* Vorher lief das ueber `vorgesetzterExterneId` und die `externeId` derselben
* 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: {
readonly quelleId: string | null;
readonly vorgesetzterExterneId: string | null;
readonly vorgesetzterId: string | null;
}): Promise<{ anzeigename: string; sam: string | null } | null> {
if (!person.vorgesetzterExterneId || !person.quelleId) return null;
return prisma.mitarbeiter.findFirst({
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
if (!person.vorgesetzterId) return null;
return prisma.mitarbeiter.findUnique({
where: { id: person.vorgesetzterId },
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);
});
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 () => {
const { admin } = await grundbestand();
const quelle = await quelleFuerContainer(admin);
+2
View File
@@ -31,6 +31,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde",
"Lauf_mitarbeiter_gleicher_kunde",
"Mitarbeiter_vorgesetzter_gleicher_kunde",
]);
});
@@ -83,6 +84,7 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
"Lauf_archiviert_mit_zeitpunkt",
"Lauf_begonnen_mit_zeitpunkt",
"Lauf_mitarbeiter_eindeutig",
"Mitarbeiter_vorgesetzter_nicht_selbst",
"Schrittergebnis_begruendung_noetig",
"TagCodeVergabe_form",
"Verzeichnisquelle_bindpasswort_verschluesselt",
+15
View File
@@ -333,6 +333,21 @@ describe.runIf(datenbankVerfuegbar)("Datenmodell: Regeln in der Datenbank", () =
).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 () => {
// Der Index ist partiell, sonst waere die zweite eigene Vorlage eine
// Dublette von null (#021).
+115
View File
@@ -6,12 +6,15 @@ import {
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
vorgesetztenSetzen,
} from "@/server/services/mitarbeiter";
import {
abteilungsNamen,
geraeteDesMitarbeiters,
mitarbeiterHolen,
mitarbeiterListe,
moeglicheVorgesetzte,
vorgesetzterVon,
} from "@/server/queries/mitarbeiter";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
@@ -40,6 +43,118 @@ describe.runIf(datenbankVerfuegbar)("Mitarbeiter", () => {
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", () => {
it("bildet den Anzeigenamen aus Vor- und Nachname, wenn keiner angegeben ist", async () => {
const { admin } = await grundbestand();
+49
View File
@@ -70,7 +70,10 @@ async function aufbau(modus: Betriebsmodus = Betriebsmodus.INTERN) {
abteilung: "Buchhaltung",
titel: "Sachbearbeiterin",
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",
vorgesetzterId: chefin.id,
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 () => {
// Der dn sagt, wo das Konto **wirklich** liegt; die Ziel-OU der Abteilung
// 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);
});
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 () => {
const eigene = await eigeneOrganisation();
await stammdatenSaeen(testPrisma, eigene.id);