diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b40a1..5b03dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/issues/020-vorgesetzter-manuell.md b/docs/issues/020-vorgesetzter-manuell.md index ce3ecb2..fdd75c5 100644 --- a/docs/issues/020-vorgesetzter-manuell.md +++ b/docs/issues/020-vorgesetzter-manuell.md @@ -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. diff --git a/docs/issues/README.md b/docs/issues/README.md index a376e6a..3f6e5c5 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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** | diff --git a/prisma/migrations/20261004163000_vorgesetzter/migration.sql b/prisma/migrations/20261004163000_vorgesetzter/migration.sql new file mode 100644 index 0000000..2ee9fbc --- /dev/null +++ b/prisma/migrations/20261004163000_vorgesetzter/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f2f1f73..d6fdf69 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/prisma/seed-daten/stammdaten.ts b/prisma/seed-daten/stammdaten.ts index c2436c2..f30b4fc 100644 --- a/prisma/seed-daten/stammdaten.ts +++ b/prisma/seed-daten/stammdaten.ts @@ -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(); + /** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */ + const neuAngelegt = new Set(); 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}-` : ""; diff --git a/src/app/(app)/mitarbeiter/[id]/page.tsx b/src/app/(app)/mitarbeiter/[id]/page.tsx index a5129d1..96d0c55 100644 --- a/src/app/(app)/mitarbeiter/[id]/page.tsx +++ b/src/app/(app)/mitarbeiter/[id]/page.tsx @@ -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 ? ( -
+
+
) : null} diff --git a/src/components/mitarbeiter-vorgesetzter.tsx b/src/components/mitarbeiter-vorgesetzter.tsx new file mode 100644 index 0000000..fe22ea9 --- /dev/null +++ b/src/components/mitarbeiter-vorgesetzter.tsx @@ -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(vorgesetztenSetzenAktion, {}); + + return ( +
+
+ + + +
+ + {ausDemVerzeichnis ? ( +

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

+ ) : null} + + {ergebnis.fehler ? ( +

+ {ergebnis.fehler} +

+ ) : null} + {ergebnis.erfolg ? ( +

+ {ergebnis.erfolg} +

+ ) : null} +
+ ); +} diff --git a/src/server/actions/mitarbeiter.ts b/src/server/actions/mitarbeiter.ts index c8666ef..a22a38f 100644 --- a/src/server/actions/mitarbeiter.ts +++ b/src/server/actions/mitarbeiter.ts @@ -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 { + 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). * diff --git a/src/server/queries/mitarbeiter.ts b/src/server/queries/mitarbeiter.ts index 554aaa7..e62f7c5 100644 --- a/src/server/queries/mitarbeiter.ts +++ b/src/server/queries/mitarbeiter.ts @@ -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 { + 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 { const zeilen = await prisma.mitarbeiter.findMany({ diff --git a/src/server/services/abgleich.ts b/src/server/services/abgleich.ts index 42753e9..b3c4a2b 100644 --- a/src/server/services/abgleich.ts +++ b/src/server/services/abgleich.ts @@ -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. diff --git a/src/server/services/mitarbeiter.ts b/src/server/services/mitarbeiter.ts index cb713a2..678ec00 100644 --- a/src/server/services/mitarbeiter.ts +++ b/src/server/services/mitarbeiter.ts @@ -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 { + 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 { + const gesehen = new Set([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. * diff --git a/src/server/services/platzhalter.ts b/src/server/services/platzhalter.ts index a1229b5..829fcbd 100644 --- a/src/server/services/platzhalter.ts +++ b/src/server/services/platzhalter.ts @@ -106,7 +106,7 @@ export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise { - 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 }, }); } diff --git a/tests/abgleich.test.ts b/tests/abgleich.test.ts index 92f0a11..c49a70d 100644 --- a/tests/abgleich.test.ts +++ b/tests/abgleich.test.ts @@ -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); diff --git a/tests/datenbankregeln.test.ts b/tests/datenbankregeln.test.ts index 543f7c1..a4feac5 100644 --- a/tests/datenbankregeln.test.ts +++ b/tests/datenbankregeln.test.ts @@ -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", diff --git a/tests/datenmodell.test.ts b/tests/datenmodell.test.ts index 57a988b..4d93583 100644 --- a/tests/datenmodell.test.ts +++ b/tests/datenmodell.test.ts @@ -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). diff --git a/tests/mitarbeiter.test.ts b/tests/mitarbeiter.test.ts index 4e5d2b0..2e8519c 100644 --- a/tests/mitarbeiter.test.ts +++ b/tests/mitarbeiter.test.ts @@ -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(); diff --git a/tests/platzhalter.test.ts b/tests/platzhalter.test.ts index bc08c6b..f4d5397 100644 --- a/tests/platzhalter.test.ts +++ b/tests/platzhalter.test.ts @@ -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. diff --git a/tests/seed.test.ts b/tests/seed.test.ts index 096aef2..feeb06b 100644 --- a/tests/seed.test.ts +++ b/tests/seed.test.ts @@ -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);